diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e41bd692 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +OpenCtx is an open standard for annotating code with contextual information from various development tools. It's a TypeScript monorepo using pnpm workspaces. + +## Build Commands + +```bash +# Install dependencies (use correct pnpm version) +npm install -g pnpm@8.6.7 +pnpm i + +# Build everything +pnpm build # Runs prebuild then TypeScript compilation +pnpm bundle # Builds and bundles all packages + +# Development +pnpm watch # TypeScript watch mode +pnpm generate # Run code generation + +# Code quality +pnpm check # Run linting (Biome + Stylelint) +pnpm biome # Format and lint with auto-fix + +# Testing +pnpm test # Run all tests +pnpm test:unit # Run unit tests once +pnpm test:integration # Run VS Code integration tests +pnpm test # Run specific test files +``` + +## Architecture + +### Monorepo Structure +- **lib/** - Core libraries + - `client` - Client library for consuming OpenCtx + - `protocol` - Protocol type definitions + - `provider` - Base provider implementation + - `schema` - JSON Schema definitions +- **client/** - Platform-specific client implementations (VS Code, browser, CodeMirror, Monaco) +- **provider/** - OpenCtx providers (GitHub, Storybook, Prometheus, etc.) +- **web/** - Documentation website (openctx.org) + +### Provider Pattern +Providers implement the standard interface: +```typescript +interface Provider { + meta(params: MetaParams, settings: S): MetaResult + mentions?(params: MentionsParams, settings: S): Promise + items?(params: ItemsParams, settings: S): Promise + annotations?(params: AnnotationsParams, settings: S): Promise +} +``` + +### Development Workflow for Providers +1. Create provider in `provider//` +2. Must include: `index.ts`, `package.json`, `tsconfig.json` +3. Export default provider implementation +4. Bundle format: ESM for distribution +5. Settings validation should happen in provider methods + +### TypeScript Configuration +- Uses project references for efficient builds +- Strict mode enabled with all checks +- Target: ESNext, Module: NodeNext +- Each package extends `tsconfig.base.json` + +### Code Style +- Formatter: Biome with 4-space indentation +- Line width: 105 characters +- Single quotes, semicolons as needed +- Trailing commas enabled + +### Testing +- Framework: Vitest +- Test files: `*.test.ts` or in `test/` directories +- Each package can have its own `vitest.config.ts` +- Run specific test: `pnpm test path/to/test.ts` + +### Common Development Tasks + +When working on providers: +1. Navigation mode: Use `isNavigation` flag in mention data +2. Page numbering: Parse with `PATTERNS.PAGE_NUMBERS` regex +3. Error handling: Use `createErrorItem()` for user-friendly errors +4. Caching: Use `QuickLRU` for API responses +5. Debouncing: Implement for search/mention operations + +When working on client integrations: +1. Use `@openctx/client` for standardized client behavior +2. Implement platform-specific UI in client packages +3. Follow existing patterns in similar client implementations \ No newline at end of file diff --git a/provider/context7/.gitignore b/provider/context7/.gitignore new file mode 100644 index 00000000..f2c35ea9 --- /dev/null +++ b/provider/context7/.gitignore @@ -0,0 +1,3 @@ +!dist +dist/* +!dist/bundle.js diff --git a/provider/context7/README.md b/provider/context7/README.md new file mode 100644 index 00000000..493fc261 --- /dev/null +++ b/provider/context7/README.md @@ -0,0 +1,78 @@ +# Context7 context provider for OpenCtx + +[OpenCtx](https://openctx.org) context provider for bringing Context7 repository documentation into code AI and editors. + +## Usage + +Configure your OpenCtx client: + +```json +"openctx.providers": { + // ...other providers... + "https://raw.githubusercontent.com/sato-ke/openctx/refs/heads/feat/context7/provider/context7/dist/bundle.js": { + "tokens": 6000, // Maximum number of tokens to return (required) + "mentionLimit": 3 // (Optional) Maximum number of libraries per request (default: 5, max: 20) + } +} +``` + +### How to use in your editor + +This provider supports navigation mode for AI-assisted library discovery: + +1. **Navigation Mode** (get library listing): + - `@context7 ` - Shows navigation with all matching libraries + - Example: `@context7 react` + +2. **Direct Library Access** (after seeing navigation): + - `@context7 ` - Access specific libraries by number + - Examples: + - `@context7 react 1` - Get library #1 + - `@context7 react 1/3/5` - Get libraries #1, #3, and #5 + - `@context7 react 2/4 authentication` - Get libraries #2 and #4 with authentication topic filter + +## Features + +- **Navigation Mode**: Browse available libraries with detailed information +- **Page Number Selection**: AI can directly access specific libraries using numbers from navigation +- **Topic Filtering**: Optional topic keyword to filter documentation content + +## Context included + +Repository documentation: +- Library navigation with ID, description, token count, and trust score +- Full documentation content for selected libraries +- Topic-filtered content when topic keyword is specified +- Content formatted as plain text for AI consumption + +## Configuration + +- `tokens` — Maximum number of tokens to return (required). Example: `6000`. +- `mentionLimit` — (Optional) Maximum number of libraries that can be selected at once (default: 3, max: 20). + +## How it works + +1. **Navigation Request**: When you type `@context7 `, the provider searches for matching libraries and returns a navigation menu +2. **Library Selection**: AI can then request specific libraries using the numbers shown in navigation +3. **Content Fetching**: Selected libraries' documentation is fetched with optional topic filtering +4. **Token Limiting**: Content is limited to the configured token count to fit within AI context limits + +## Recommended VS Code Settings + +To optimize AI interaction with context7 provider, add this to your `settings.json`: + +```json +{ + "cody.chat.preInstruction": "### context7 provider usage\n`@context7 ` - Get library navigation to see available libraries and their numbers\n`@context7 [page numbers]` - Get specific libraries using numbers from navigation (e.g., 1/3/5)\n`@context7 [page numbers] [topic]` - Get specific libraries with topic filter (e.g., 1/3 authentication)\n\n#### Important notes:\n- Page numbers are only available after seeing navigation first\n- For deep research: (1) get navigation, (2) identify relevant libraries, (3) fetch specific libraries by number\n- Topic filter helps narrow down documentation to specific areas" +} +``` + +**Key points for AI behavior**: +- Always start with navigation (`@context7 `) to understand available libraries +- Page numbers are provider-generated and only visible in navigation responses +- Use specific page numbers for targeted documentation retrieval +- Apply topic filters when focusing on specific aspects of the library + +## Development + +- License: Apache 2.0 diff --git a/provider/context7/api.ts b/provider/context7/api.ts new file mode 100644 index 00000000..56a376fd --- /dev/null +++ b/provider/context7/api.ts @@ -0,0 +1,138 @@ +import QuickLRU from 'quick-lru' +import type { JsonDocs, SearchResponse } from './types.js' + +const CONTEXT7_API_BASE_URL = 'https://context7.com/api' + +const searchCache = new QuickLRU({ + maxSize: 500, + maxAge: 1000 * 60 * 30, +}) + +function debounce any>( + fn: F, + timeout: number, + cancelledReturn: Awaited>, +): (...args: Parameters) => Promise>> { + let controller = new AbortController() + let timeoutId: NodeJS.Timeout + + return (...args) => { + return new Promise(resolve => { + controller.abort() + + controller = new AbortController() + const { signal } = controller + + timeoutId = setTimeout(async () => { + const result = await fn(...args) + resolve(result) + }, timeout) + + signal.addEventListener('abort', () => { + clearTimeout(timeoutId) + resolve(cancelledReturn) + }) + }) + } +} + +/** + * Searches for libraries matching the given query + * @param query The search query + * @returns Search results or null if the request fails + */ +export const searchLibraries = debounce(_searchLibraries, 300, { results: [] }) +export async function _searchLibraries(query: string): Promise { + const cacheKey = `search-${query}` + if (searchCache.has(cacheKey)) { + return searchCache.get(cacheKey)! + } + + try { + const url = new URL(`${CONTEXT7_API_BASE_URL}/v1/search`) + url.searchParams.set('query', query) + const response = await fetch(url) + + if (!response.ok) { + console.error(`Failed to search libraries: ${response.status}`) + return null + } + + const data = (await response.json()) as SearchResponse + + if (data.results.length > 0) { + searchCache.set(cacheKey, data) + } + return data + } catch (error) { + console.error('Error searching libraries:', error) + return null + } +} + +/** + * Fetches documentation context for a specific library + * @param libraryId The library ID to fetch documentation for + * @param tokens Number of tokens to request + * @param options Options for the request + * @returns The documentation text or null if the request fails + */ +export async function fetchLibraryDocumentation( + libraryId: string, + tokens: number, + options: { + topic?: string + } = {}, +): Promise { + try { + if (libraryId.startsWith('/')) { + libraryId = libraryId.slice(1) + } + const url = new URL(`${CONTEXT7_API_BASE_URL}/v1/${libraryId}`) + url.searchParams.set('tokens', tokens.toString()) + url.searchParams.set('type', 'txt') + if (options.topic) url.searchParams.set('topic', options.topic) + const response = await fetch(url, { + headers: { + 'X-Context7-Source': 'mcp-server', + }, + }) + if (!response.ok) { + console.error(`Failed to fetch documentation: ${response.status}`) + return null + } + const text = await response.text() + if (!text || text === 'No content available' || text === 'No context data available') { + return null + } + + return text + } catch (error) { + console.error('Error fetching library documentation:', error) + return null + } +} + +/** + * Process JSON response and convert it to a specific format + * + * @param {string} jsonText - The JSON text to process + * @returns {string} Converted JSON text, or the original text if processing fails + */ +export function processJsonResponse(jsonText: string): string { + try { + const data = JSON.parse(jsonText) as JsonDocs[] + const formattedData = data.map(item => ({ + id: item.codeId, + title: item.codeTitle, + description: item.codeDescription, + lang: item.codeLanguage, + page: item.pageTitle, + codes: item.codeList.map(item => item.code), + })) + return JSON.stringify(formattedData) + } catch (error) { + console.error('Error processing JSON response:', error) + return jsonText + } +} diff --git a/provider/context7/core.test.ts b/provider/context7/core.test.ts new file mode 100644 index 00000000..436bfb93 --- /dev/null +++ b/provider/context7/core.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' +import { + filterLibrariesByPageNumbers, + generateNavigation, + parseInputQuery, + validateSettings, +} from './core.js' +import type { SearchResult } from './types.js' + +describe('core.ts', () => { + describe('parseInputQuery', () => { + it('throws error for empty query', () => { + expect(() => parseInputQuery('')).toThrow('Repository query is required') + expect(() => parseInputQuery(' ')).toThrow('Repository query is required') + }) + + it('parses simple library query', () => { + const result = parseInputQuery('react') + expect(result).toEqual({ + repositoryQuery: 'react', + topicKeyword: undefined, + pageNumbers: undefined, + }) + }) + + it('converts repository query to lowercase', () => { + const result = parseInputQuery('React') + expect(result).toEqual({ + repositoryQuery: 'react', + topicKeyword: undefined, + pageNumbers: undefined, + }) + }) + + it('parses query with topic keyword', () => { + const result = parseInputQuery('react hooks') + expect(result).toEqual({ + repositoryQuery: 'react', + topicKeyword: 'hooks', + pageNumbers: undefined, + }) + }) + + it('parses query with multiple topic keywords', () => { + const result = parseInputQuery('react custom hooks tutorial') + expect(result).toEqual({ + repositoryQuery: 'react', + topicKeyword: 'custom hooks tutorial', + pageNumbers: undefined, + }) + }) + + it('parses query with single page number', () => { + const result = parseInputQuery('react 1') + expect(result).toEqual({ + repositoryQuery: 'react', + topicKeyword: undefined, + pageNumbers: [1], + }) + }) + + it('parses query with multiple page numbers', () => { + const result = parseInputQuery('react 1/3/5') + expect(result).toEqual({ + repositoryQuery: 'react', + topicKeyword: undefined, + pageNumbers: [1, 3, 5], + }) + }) + + it('parses query with page numbers and topic', () => { + const result = parseInputQuery('react 1/3 hooks') + expect(result).toEqual({ + repositoryQuery: 'react', + topicKeyword: 'hooks', + pageNumbers: [1, 3], + }) + }) + + it('treats invalid page numbers as topic', () => { + const result = parseInputQuery('react 0/5') + expect(result).toEqual({ + repositoryQuery: 'react', + topicKeyword: '0/5', + pageNumbers: undefined, + }) + }) + }) + + describe('generateNavigation', () => { + const mockLibraries: SearchResult[] = [ + { + id: 'react/react', + title: 'React', + description: 'A JavaScript library for building user interfaces', + branch: 'main', + lastUpdate: '2024-01-01', + state: 'finalized', + totalTokens: 50000, + totalSnippets: 100, + totalPages: 50, + stars: 210000, + trustScore: 95, + }, + { + id: 'facebook/react-native', + title: 'React Native', + description: undefined, + branch: 'main', + lastUpdate: '2024-01-02', + state: 'finalized', + totalTokens: 80000, + totalSnippets: 200, + totalPages: 80, + stars: 110000, + trustScore: 90, + }, + ] + + it('returns empty message when no libraries found', () => { + const result = generateNavigation([], 'react', 3) + expect(result).toBe('No libraries found for "react".') + }) + + it('generates navigation with library details', () => { + const result = generateNavigation(mockLibraries, 'react', 3) + + expect(result).toContain('Context7 library search results for "react"') + expect(result).toContain('### 1. React') + expect(result).toContain('- **ID**: react/react') + expect(result).toContain('- **Description**: A JavaScript library for building user interfaces') + expect(result).toContain('- **Tokens**: 50,000') + expect(result).toContain('- **Trust Score**: 95') + expect(result).toContain('- **Stars**: 210000') + expect(result).toContain('- **Last Update**: 2024-01-01') + + expect(result).toContain('### 2. React Native') + expect(result).toContain('- **Description**: No description') + }) + + it('includes access instructions', () => { + const result = generateNavigation(mockLibraries, 'react', 5) + + expect(result).toContain('@context7 react 1/3/5 (maximum 5 libraries per request)') + expect(result).toContain('@context7 react 2') + expect(result).toContain('@context7 react 1/3 authentication') + }) + }) + + describe('filterLibrariesByPageNumbers', () => { + const mockLibraries: SearchResult[] = [ + { + id: 'lib1', + title: 'Library 1', + description: 'First library', + branch: 'main', + lastUpdate: '2024-01-01', + state: 'finalized', + totalTokens: 1000, + totalSnippets: 10, + totalPages: 5, + stars: 100, + trustScore: 80, + }, + { + id: 'lib2', + title: 'Library 2', + description: 'Second library', + branch: 'main', + lastUpdate: '2024-01-02', + state: 'finalized', + totalTokens: 2000, + totalSnippets: 20, + totalPages: 10, + stars: 200, + trustScore: 85, + }, + { + id: 'lib3', + title: 'Library 3', + description: 'Third library', + branch: 'main', + lastUpdate: '2024-01-03', + state: 'finalized', + totalTokens: 3000, + totalSnippets: 30, + totalPages: 15, + stars: 300, + trustScore: 90, + }, + ] + + it('returns empty array for empty page numbers', () => { + const result = filterLibrariesByPageNumbers(mockLibraries, []) + expect(result).toEqual([]) + }) + + it('returns single library by page number', () => { + const result = filterLibrariesByPageNumbers(mockLibraries, [2]) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('lib2') + }) + + it('returns multiple libraries in order', () => { + const result = filterLibrariesByPageNumbers(mockLibraries, [3, 1, 2]) + expect(result).toHaveLength(3) + expect(result[0].id).toBe('lib3') + expect(result[1].id).toBe('lib1') + expect(result[2].id).toBe('lib2') + }) + + it('filters out invalid page numbers', () => { + const result = filterLibrariesByPageNumbers(mockLibraries, [0, 1, 4, 5]) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('lib1') + }) + + it('handles duplicate page numbers', () => { + const result = filterLibrariesByPageNumbers(mockLibraries, [1, 1, 2]) + expect(result).toHaveLength(3) + expect(result[0].id).toBe('lib1') + expect(result[1].id).toBe('lib1') + expect(result[2].id).toBe('lib2') + }) + }) + + describe('validateSettings', () => { + it('throws error for non-object settings', () => { + expect(() => validateSettings(null)).toThrow('Settings must be an object') + expect(() => validateSettings(undefined)).toThrow('Settings must be an object') + expect(() => validateSettings('string')).toThrow('Settings must be an object') + expect(() => validateSettings(123)).toThrow('Settings must be an object') + }) + + it('throws error when tokens is missing', () => { + expect(() => validateSettings({})).toThrow('Missing settings: ["tokens"]') + expect(() => validateSettings({ mentionLimit: 3 })).toThrow('Missing settings: ["tokens"]') + }) + + it('does not throw when tokens is present', () => { + expect(() => validateSettings({ tokens: 6000 })).not.toThrow() + expect(() => validateSettings({ tokens: 6000, mentionLimit: 3 })).not.toThrow() + }) + + it('does not throw for additional properties', () => { + expect(() => validateSettings({ tokens: 6000, extra: 'value' })).not.toThrow() + }) + }) +}) \ No newline at end of file diff --git a/provider/context7/core.ts b/provider/context7/core.ts new file mode 100644 index 00000000..3bbef342 --- /dev/null +++ b/provider/context7/core.ts @@ -0,0 +1,131 @@ +/** + * Core functions for Context7 OpenCtx Provider + */ + +import type { ParsedQuery, SearchResult } from './types.js' +import { PATTERNS } from './types.js' + +/** + * Parse user input into repository query, topic keyword, and page numbers + * @param query - Input in the form " [page numbers or topic]" + * @returns Parsed result + * @throws Error - If the input format is invalid + */ +export function parseInputQuery(query: string): ParsedQuery { + const trimmed = query.trim() + if (!trimmed) { + throw new Error('Repository query is required') + } + + const parts = trimmed.split(/\s+/) + const repositoryQuery = parts[0].toLowerCase() + const remainingParts = parts.slice(1).join(' ') + + let topicKeyword: string | undefined = undefined + let pageNumbers: number[] | undefined = undefined + + if (remainingParts) { + // Check if it starts with page numbers pattern + const words = remainingParts.split(/\s+/) + const firstWord = words[0] + + if (PATTERNS.PAGE_NUMBERS.test(firstWord)) { + const numbers = firstWord.split('/').map(num => Number.parseInt(num, 10)) + if (numbers.every(num => Number.isInteger(num) && num > 0)) { + pageNumbers = numbers + // Rest is topic keyword + if (words.length > 1) { + topicKeyword = words.slice(1).join(' ') + } + } else { + topicKeyword = remainingParts + } + } else { + topicKeyword = remainingParts + } + } + + return { repositoryQuery, topicKeyword, pageNumbers } +} + +/** + * Generate navigation content for AI + * @param libraries - Array of library search results + * @param repositoryQuery - The search query used + * @param maxMentionItems - Maximum number of libraries that can be selected + * @returns Markdown text for navigation + */ +export function generateNavigation( + libraries: SearchResult[], + repositoryQuery: string, + maxMentionItems: number +): string { + if (libraries.length === 0) { + return `No libraries found for "${repositoryQuery}".` + } + + const libraryList = libraries + .map((lib, index) => { + return `### ${index + 1}. ${lib.title} +- **ID**: ${lib.id} +- **Description**: ${lib.description || 'No description'} +- **Tokens**: ${lib.totalTokens.toLocaleString()} +- **Total Snippets**: ${lib.totalSnippets} +- **Stars**: ${lib.stars}` + }) + .join('\n\n') + + return `Context7 library search results for "${repositoryQuery}". + +## How to Access Specific Libraries +- Multiple libraries: @context7 ${repositoryQuery} 1/3/5 (maximum ${maxMentionItems} libraries per request) +- Single library: @context7 ${repositoryQuery} 2 +- With topic filter: @context7 ${repositoryQuery} 1/3 authentication + +## Selection Method +Based on your needs, choose up to ${maxMentionItems} most relevant libraries from the list below. + +## Available Libraries + +${libraryList}` +} + +/** + * Filter libraries by page numbers + * @param libraries - Array of libraries to filter + * @param pageNumbers - Array of page numbers (1-based) + * @returns Filtered array of libraries in the order of specified page numbers + */ +export function filterLibrariesByPageNumbers( + libraries: SearchResult[], + pageNumbers: number[] +): SearchResult[] { + const result: SearchResult[] = [] + + for (const pageNum of pageNumbers) { + // Convert to 0-based index and check bounds + const index = pageNum - 1 + if (index >= 0 && index < libraries.length) { + result.push(libraries[index]) + } + } + + return result +} + +/** + * Validate settings + * @param settings - Settings object to validate + * @throws Error if required settings are missing + */ +export function validateSettings(settings: unknown): void { + if (!settings || typeof settings !== 'object') { + throw new Error('Settings must be an object') + } + + const settingsObj = settings as Record + const missingKeys = ['tokens'].filter(key => !(key in settingsObj)) + if (missingKeys.length > 0) { + throw new Error(`Missing settings: ${JSON.stringify(missingKeys)}`) + } +} diff --git a/provider/context7/dist/bundle.js b/provider/context7/dist/bundle.js new file mode 100644 index 00000000..d5e89cab --- /dev/null +++ b/provider/context7/dist/bundle.js @@ -0,0 +1,555 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// index.ts +var context7_exports = {}; +__export(context7_exports, { + default: () => context7_default +}); +module.exports = __toCommonJS(context7_exports); + +// ../../node_modules/.pnpm/quick-lru@7.0.1/node_modules/quick-lru/index.js +var QuickLRU = class extends Map { + #size = 0; + #cache = /* @__PURE__ */ new Map(); + #oldCache = /* @__PURE__ */ new Map(); + #maxSize; + #maxAge; + #onEviction; + constructor(options = {}) { + super(); + if (!(options.maxSize && options.maxSize > 0)) { + throw new TypeError("`maxSize` must be a number greater than 0"); + } + if (typeof options.maxAge === "number" && options.maxAge === 0) { + throw new TypeError("`maxAge` must be a number greater than 0"); + } + this.#maxSize = options.maxSize; + this.#maxAge = options.maxAge || Number.POSITIVE_INFINITY; + this.#onEviction = options.onEviction; + } + // For tests. + get __oldCache() { + return this.#oldCache; + } + #emitEvictions(cache) { + if (typeof this.#onEviction !== "function") { + return; + } + for (const [key, item] of cache) { + this.#onEviction(key, item.value); + } + } + #deleteIfExpired(key, item) { + if (typeof item.expiry === "number" && item.expiry <= Date.now()) { + if (typeof this.#onEviction === "function") { + this.#onEviction(key, item.value); + } + return this.delete(key); + } + return false; + } + #getOrDeleteIfExpired(key, item) { + const deleted = this.#deleteIfExpired(key, item); + if (deleted === false) { + return item.value; + } + } + #getItemValue(key, item) { + return item.expiry ? this.#getOrDeleteIfExpired(key, item) : item.value; + } + #peek(key, cache) { + const item = cache.get(key); + return this.#getItemValue(key, item); + } + #set(key, value) { + this.#cache.set(key, value); + this.#size++; + if (this.#size >= this.#maxSize) { + this.#size = 0; + this.#emitEvictions(this.#oldCache); + this.#oldCache = this.#cache; + this.#cache = /* @__PURE__ */ new Map(); + } + } + #moveToRecent(key, item) { + this.#oldCache.delete(key); + this.#set(key, item); + } + *#entriesAscending() { + for (const item of this.#oldCache) { + const [key, value] = item; + if (!this.#cache.has(key)) { + const deleted = this.#deleteIfExpired(key, value); + if (deleted === false) { + yield item; + } + } + } + for (const item of this.#cache) { + const [key, value] = item; + const deleted = this.#deleteIfExpired(key, value); + if (deleted === false) { + yield item; + } + } + } + get(key) { + if (this.#cache.has(key)) { + const item = this.#cache.get(key); + return this.#getItemValue(key, item); + } + if (this.#oldCache.has(key)) { + const item = this.#oldCache.get(key); + if (this.#deleteIfExpired(key, item) === false) { + this.#moveToRecent(key, item); + return item.value; + } + } + } + set(key, value, { maxAge = this.#maxAge } = {}) { + const expiry = typeof maxAge === "number" && maxAge !== Number.POSITIVE_INFINITY ? Date.now() + maxAge : void 0; + if (this.#cache.has(key)) { + this.#cache.set(key, { + value, + expiry + }); + } else { + this.#set(key, { value, expiry }); + } + return this; + } + has(key) { + if (this.#cache.has(key)) { + return !this.#deleteIfExpired(key, this.#cache.get(key)); + } + if (this.#oldCache.has(key)) { + return !this.#deleteIfExpired(key, this.#oldCache.get(key)); + } + return false; + } + peek(key) { + if (this.#cache.has(key)) { + return this.#peek(key, this.#cache); + } + if (this.#oldCache.has(key)) { + return this.#peek(key, this.#oldCache); + } + } + delete(key) { + const deleted = this.#cache.delete(key); + if (deleted) { + this.#size--; + } + return this.#oldCache.delete(key) || deleted; + } + clear() { + this.#cache.clear(); + this.#oldCache.clear(); + this.#size = 0; + } + resize(newSize) { + if (!(newSize && newSize > 0)) { + throw new TypeError("`maxSize` must be a number greater than 0"); + } + const items = [...this.#entriesAscending()]; + const removeCount = items.length - newSize; + if (removeCount < 0) { + this.#cache = new Map(items); + this.#oldCache = /* @__PURE__ */ new Map(); + this.#size = items.length; + } else { + if (removeCount > 0) { + this.#emitEvictions(items.slice(0, removeCount)); + } + this.#oldCache = new Map(items.slice(removeCount)); + this.#cache = /* @__PURE__ */ new Map(); + this.#size = 0; + } + this.#maxSize = newSize; + } + *keys() { + for (const [key] of this) { + yield key; + } + } + *values() { + for (const [, value] of this) { + yield value; + } + } + *[Symbol.iterator]() { + for (const item of this.#cache) { + const [key, value] = item; + const deleted = this.#deleteIfExpired(key, value); + if (deleted === false) { + yield [key, value.value]; + } + } + for (const item of this.#oldCache) { + const [key, value] = item; + if (!this.#cache.has(key)) { + const deleted = this.#deleteIfExpired(key, value); + if (deleted === false) { + yield [key, value.value]; + } + } + } + } + *entriesDescending() { + let items = [...this.#cache]; + for (let i = items.length - 1; i >= 0; --i) { + const item = items[i]; + const [key, value] = item; + const deleted = this.#deleteIfExpired(key, value); + if (deleted === false) { + yield [key, value.value]; + } + } + items = [...this.#oldCache]; + for (let i = items.length - 1; i >= 0; --i) { + const item = items[i]; + const [key, value] = item; + if (!this.#cache.has(key)) { + const deleted = this.#deleteIfExpired(key, value); + if (deleted === false) { + yield [key, value.value]; + } + } + } + } + *entriesAscending() { + for (const [key, value] of this.#entriesAscending()) { + yield [key, value.value]; + } + } + get size() { + if (!this.#size) { + return this.#oldCache.size; + } + let oldCacheSize = 0; + for (const key of this.#oldCache.keys()) { + if (!this.#cache.has(key)) { + oldCacheSize++; + } + } + return Math.min(this.#size + oldCacheSize, this.#maxSize); + } + get maxSize() { + return this.#maxSize; + } + entries() { + return this.entriesAscending(); + } + forEach(callbackFunction, thisArgument = this) { + for (const [key, value] of this.entriesAscending()) { + callbackFunction.call(thisArgument, value, key, this); + } + } + get [Symbol.toStringTag]() { + return "QuickLRU"; + } + toString() { + return `QuickLRU(${this.size}/${this.maxSize})`; + } + [Symbol.for("nodejs.util.inspect.custom")]() { + return this.toString(); + } +}; + +// api.ts +var CONTEXT7_API_BASE_URL = "https://context7.com/api"; +var searchCache = new QuickLRU({ + maxSize: 500, + maxAge: 1e3 * 60 * 30 +}); +function debounce(fn, timeout, cancelledReturn) { + let controller = new AbortController(); + let timeoutId; + return (...args) => { + return new Promise((resolve) => { + controller.abort(); + controller = new AbortController(); + const { signal } = controller; + timeoutId = setTimeout(async () => { + const result = await fn(...args); + resolve(result); + }, timeout); + signal.addEventListener("abort", () => { + clearTimeout(timeoutId); + resolve(cancelledReturn); + }); + }); + }; +} +var searchLibraries = debounce(_searchLibraries, 300, { results: [] }); +async function _searchLibraries(query) { + const cacheKey = `search-${query}`; + if (searchCache.has(cacheKey)) { + return searchCache.get(cacheKey); + } + try { + const url = new URL(`${CONTEXT7_API_BASE_URL}/v1/search`); + url.searchParams.set("query", query); + const response = await fetch(url); + if (!response.ok) { + console.error(`Failed to search libraries: ${response.status}`); + return null; + } + const data = await response.json(); + if (data.results.length > 0) { + searchCache.set(cacheKey, data); + } + return data; + } catch (error) { + console.error("Error searching libraries:", error); + return null; + } +} +async function fetchLibraryDocumentation(libraryId, tokens, options = {}) { + try { + if (libraryId.startsWith("/")) { + libraryId = libraryId.slice(1); + } + const url = new URL(`${CONTEXT7_API_BASE_URL}/v1/${libraryId}`); + url.searchParams.set("tokens", tokens.toString()); + url.searchParams.set("type", "txt"); + if (options.topic) url.searchParams.set("topic", options.topic); + const response = await fetch(url, { + headers: { + "X-Context7-Source": "mcp-server" + } + }); + if (!response.ok) { + console.error(`Failed to fetch documentation: ${response.status}`); + return null; + } + const text = await response.text(); + if (!text || text === "No content available" || text === "No context data available") { + return null; + } + return text; + } catch (error) { + console.error("Error fetching library documentation:", error); + return null; + } +} + +// types.ts +var DEFAULT_SETTINGS = { + mentionLimit: 5 +}; +var SETTINGS_LIMITS = { + mentionLimit: { min: 1, max: 20 } +}; +var PATTERNS = { + PAGE_NUMBERS: /^\d+(?:\/\d+)*$/ +}; + +// core.ts +function parseInputQuery(query) { + const trimmed = query.trim(); + if (!trimmed) { + throw new Error("Repository query is required"); + } + const parts = trimmed.split(/\s+/); + const repositoryQuery = parts[0].toLowerCase(); + const remainingParts = parts.slice(1).join(" "); + let topicKeyword = void 0; + let pageNumbers = void 0; + if (remainingParts) { + const words = remainingParts.split(/\s+/); + const firstWord = words[0]; + if (PATTERNS.PAGE_NUMBERS.test(firstWord)) { + const numbers = firstWord.split("/").map((num) => Number.parseInt(num, 10)); + if (numbers.every((num) => Number.isInteger(num) && num > 0)) { + pageNumbers = numbers; + if (words.length > 1) { + topicKeyword = words.slice(1).join(" "); + } + } else { + topicKeyword = remainingParts; + } + } else { + topicKeyword = remainingParts; + } + } + return { repositoryQuery, topicKeyword, pageNumbers }; +} +function generateNavigation(libraries, repositoryQuery, maxMentionItems) { + if (libraries.length === 0) { + return `No libraries found for "${repositoryQuery}".`; + } + const libraryList = libraries.map((lib, index) => { + return `### ${index + 1}. ${lib.title} +- **ID**: ${lib.id} +- **Description**: ${lib.description || "No description"} +- **Tokens**: ${lib.totalTokens.toLocaleString()} +- **Trust Score**: ${lib.trustScore} +- **Stars**: ${lib.stars} +- **Last Update**: ${lib.lastUpdate}`; + }).join("\n\n"); + return `Context7 library search results for "${repositoryQuery}". + +## How to Access Specific Libraries +- Multiple libraries: @context7 ${repositoryQuery} 1/3/5 (maximum ${maxMentionItems} libraries per request) +- Single library: @context7 ${repositoryQuery} 2 +- With topic filter: @context7 ${repositoryQuery} 1/3 authentication + +## Selection Method +Based on your needs, choose up to ${maxMentionItems} most relevant libraries from the list below. + +## Available Libraries + +${libraryList}`; +} +function filterLibrariesByPageNumbers(libraries, pageNumbers) { + const result = []; + for (const pageNum of pageNumbers) { + const index = pageNum - 1; + if (index >= 0 && index < libraries.length) { + result.push(libraries[index]); + } + } + return result; +} +function validateSettings(settings) { + if (!settings || typeof settings !== "object") { + throw new Error("Settings must be an object"); + } + const settingsObj = settings; + const missingKeys = ["tokens"].filter((key) => !(key in settingsObj)); + if (missingKeys.length > 0) { + throw new Error(`Missing settings: ${JSON.stringify(missingKeys)}`); + } +} + +// index.ts +var CONTEXT7_BASE_URL = "https://context7.com"; +var Context7Provider = { + meta(_params, _settings) { + return { + name: "Context7", + mentions: { label: "type [page numbers] [topic]" } + }; + }, + async mentions(params, settings) { + validateSettings(settings); + if (!params.query || params.query.trim().length === 0) { + return []; + } + try { + const { repositoryQuery, topicKeyword, pageNumbers } = parseInputQuery(params.query); + const response = await searchLibraries(repositoryQuery); + if (!response || response.results.length === 0) { + return [{ + title: `No results found`, + uri: "", + description: `No libraries found for "${repositoryQuery}"`, + data: { + isError: true, + content: `No libraries found for "${repositoryQuery}". Please check your search query.` + } + }]; + } + const mentionLimit = typeof settings.mentionLimit === "number" ? Math.min(Math.max(settings.mentionLimit, 1), SETTINGS_LIMITS.mentionLimit.max) : DEFAULT_SETTINGS.mentionLimit; + const libraries = response.results; + if (pageNumbers) { + const selectedLibraries = filterLibrariesByPageNumbers(libraries, pageNumbers); + const limitedLibraries = selectedLibraries.slice(0, mentionLimit); + return limitedLibraries.map((lib) => ({ + title: `${lib.title} [${lib.totalTokens.toLocaleString()}]`, + uri: `${CONTEXT7_BASE_URL}/${lib.id}`, + description: lib.description || "No description", + data: { + id: lib.id, + topicKeyword, + isNavigation: false + } + })); + } + const navigationContent = generateNavigation(libraries, repositoryQuery, mentionLimit); + return [{ + title: `Context7 Navigation: ${repositoryQuery}`, + uri: `${CONTEXT7_BASE_URL}/search?q=${encodeURIComponent(repositoryQuery)}`, + description: `${libraries.length} libraries found`, + data: { + content: navigationContent, + isNavigation: true, + libraries, + // Store all libraries, not limited + topicKeyword + } + }]; + } catch (error) { + return [{ + title: "Error", + uri: "", + description: error instanceof Error ? error.message : "Unknown error", + data: { + isError: true, + content: `Error: ${error instanceof Error ? error.message : "Unknown error"}` + } + }]; + } + }, + async items(params, settings) { + validateSettings(settings); + const mentionData = params.mention?.data; + if (!mentionData) { + return []; + } + if (mentionData.isError) { + return [{ + title: params.mention?.title || "Error", + ui: { hover: { text: "Error occurred" } }, + ai: { content: mentionData.content || "An error occurred" } + }]; + } + if (mentionData.isNavigation) { + return [{ + title: params.mention?.title || "Context7 Navigation", + url: params.mention?.uri, + ui: { hover: { text: "Library navigation" } }, + ai: { content: mentionData.content || "" } + }]; + } + if (mentionData.id) { + const response = await fetchLibraryDocumentation(mentionData.id, settings.tokens, { + topic: mentionData.topicKeyword + }); + if (!response) { + return [{ + title: `Failed to fetch documentation`, + ui: { hover: { text: "Failed to fetch" } }, + ai: { content: `Failed to fetch documentation for ${mentionData.id}` } + }]; + } + const topicPart = mentionData.topicKeyword ? ` / topic: ${mentionData.topicKeyword}` : ""; + return [{ + title: `Context7: ${mentionData.id}${topicPart}`, + url: `${CONTEXT7_BASE_URL}/${mentionData.id}/llms.txt?topic=${mentionData.topicKeyword || ""}&tokens=${settings.tokens}`, + ui: { hover: { text: `${mentionData.id}${mentionData.topicKeyword ? `#${mentionData.topicKeyword}` : ""}` } }, + ai: { content: response } + }]; + } + return []; + } +}; +var context7_default = Context7Provider; diff --git a/provider/context7/index.ts b/provider/context7/index.ts new file mode 100644 index 00000000..615bcd45 --- /dev/null +++ b/provider/context7/index.ts @@ -0,0 +1,155 @@ +import type { + ItemsParams, + ItemsResult, + MentionsParams, + MentionsResult, + MetaParams, + MetaResult, + Provider, +} from '@openctx/provider' +import { fetchLibraryDocumentation, searchLibraries } from './api.js' +import { filterLibrariesByPageNumbers, generateNavigation, parseInputQuery, validateSettings } from './core.js' +import type { Context7MentionData, SearchResult, Settings } from './types.js' +import { DEFAULT_SETTINGS, SETTINGS_LIMITS } from './types.js' + +const CONTEXT7_BASE_URL = 'https://context7.com' + +const Context7Provider: Provider = { + meta(_params: MetaParams, _settings: Settings): MetaResult { + return { + name: 'Context7', + mentions: { label: 'type [page numbers] [topic]' }, + } + }, + + async mentions(params: MentionsParams, settings: Settings): Promise { + validateSettings(settings) + + if (!params.query || params.query.trim().length === 0) { + return [] + } + + try { + const { repositoryQuery, topicKeyword, pageNumbers } = parseInputQuery(params.query) + + // Search for libraries + const response = await searchLibraries(repositoryQuery) + if (!response || response.results.length === 0) { + return [{ + title: `No results found`, + uri: '', + description: `No libraries found for "${repositoryQuery}"`, + data: { + isError: true, + content: `No libraries found for "${repositoryQuery}". Please check your search query.` + } as Context7MentionData + }] + } + + const mentionLimit = typeof settings.mentionLimit === 'number' + ? Math.min(Math.max(settings.mentionLimit, 1), SETTINGS_LIMITS.mentionLimit.max) + : DEFAULT_SETTINGS.mentionLimit + + // Use API results as-is (no additional sorting needed) + const libraries: SearchResult[] = response.results + + // Handle page numbers + if (pageNumbers) { + const selectedLibraries = filterLibrariesByPageNumbers(libraries, pageNumbers) + // Apply mention limit after filtering by page numbers + const limitedLibraries = selectedLibraries.slice(0, mentionLimit) + + return limitedLibraries.map(lib => ({ + title: `${lib.title} [${lib.totalTokens.toLocaleString()}]`, + uri: `${CONTEXT7_BASE_URL}/${lib.id}`, + description: lib.description || 'No description', + data: { + id: lib.id, + topicKeyword, + isNavigation: false, + } as Context7MentionData + })) + } + + // If no page numbers, return navigation + // Generate navigation with all libraries (no limit applied) + const navigationContent = generateNavigation(libraries, repositoryQuery, mentionLimit) + return [{ + title: `Context7 Navigation: ${repositoryQuery}`, + uri: `${CONTEXT7_BASE_URL}/search?q=${encodeURIComponent(repositoryQuery)}`, + description: `${libraries.length} libraries found`, + data: { + content: navigationContent, + isNavigation: true, + libraries: libraries, // Store all libraries, not limited + topicKeyword, + } as Context7MentionData + }] + } catch (error) { + return [{ + title: 'Error', + uri: '', + description: error instanceof Error ? error.message : 'Unknown error', + data: { + isError: true, + content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` + } as Context7MentionData + }] + } + }, + + async items(params: ItemsParams, settings: Settings): Promise { + validateSettings(settings) + + const mentionData = params.mention?.data as Context7MentionData | undefined + if (!mentionData) { + return [] + } + + // Handle error items + if (mentionData.isError) { + return [{ + title: params.mention?.title || 'Error', + ui: { hover: { text: 'Error occurred' } }, + ai: { content: mentionData.content || 'An error occurred' } + }] + } + + // Handle navigation items + if (mentionData.isNavigation) { + return [{ + title: params.mention?.title || 'Context7 Navigation', + url: params.mention?.uri, + ui: { hover: { text: 'Library navigation' } }, + ai: { content: mentionData.content || '' } + }] + } + + // Handle regular library items + if (mentionData.id) { + const response = await fetchLibraryDocumentation(mentionData.id, settings.tokens, { + topic: mentionData.topicKeyword, + }) + + if (!response) { + return [{ + title: `Failed to fetch documentation`, + ui: { hover: { text: 'Failed to fetch' } }, + ai: { content: `Failed to fetch documentation for ${mentionData.id}` } + }] + } + + const topicPart = mentionData.topicKeyword ? ` / topic: ${mentionData.topicKeyword}` : '' + return [{ + title: `Context7: ${mentionData.id}${topicPart}`, + url: `${CONTEXT7_BASE_URL}/${mentionData.id}/llms.txt?topic=${mentionData.topicKeyword || ''}&tokens=${settings.tokens}`, + ui: { hover: { text: `${mentionData.id}${mentionData.topicKeyword ? `#${mentionData.topicKeyword}` : ''}` } }, + ai: { content: response }, + }] + } + + return [] + }, +} + +export default Context7Provider diff --git a/provider/context7/package.json b/provider/context7/package.json new file mode 100644 index 00000000..3d982b39 --- /dev/null +++ b/provider/context7/package.json @@ -0,0 +1,26 @@ +{ + "name": "@openctx/provider-context7", + "private": false, + "version": "0.0.15", + "description": "Context7 (OpenCtx provider)", + "license": "Apache-2.0", + "type": "module", + "main": "dist/bundle.js", + "types": "dist/index.d.ts", + "files": [ + "dist/bundle.js", + "dist/index.d.ts" + ], + "sideEffects": false, + "scripts": { + "bundle": "tsc --build && esbuild --log-level=error --bundle --format=cjs --outfile=dist/bundle.js index.ts", + "prepublishOnly": "tsc --build --clean && npm run --silent bundle", + "test": "vitest" + }, + "dependencies": { + "@openctx/provider": "workspace:*", + "fuzzysort": "^3.0.1", + "gpt-tokenizer": "^2.9.0", + "quick-lru": "^7.0.1" + } +} diff --git a/provider/context7/tsconfig.json b/provider/context7/tsconfig.json new file mode 100644 index 00000000..a1d94187 --- /dev/null +++ b/provider/context7/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../.config/tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "lib": ["ESNext"] + }, + "include": ["*.ts"], + "exclude": ["dist", "vitest.config.ts"], + "references": [{ "path": "../../lib/provider" }] +} diff --git a/provider/context7/types.ts b/provider/context7/types.ts new file mode 100644 index 00000000..33f2e057 --- /dev/null +++ b/provider/context7/types.ts @@ -0,0 +1,74 @@ +export interface SearchResult { + id: string + title: string + description?: string + branch: string + lastUpdate: string + state: DocumentState + totalTokens: number + totalSnippets: number + totalPages: number + stars: number + trustScore: number +} + +export interface SearchResponse { + results: SearchResult[] +} + +// Version state is still needed for validating search results +export type DocumentState = + | 'initial' + | 'parsed' + | 'finalized' + | 'invalid_docs' + | 'error' + | 'stop' + | 'delete' + +export interface JsonDocs { + codeTitle: string + codeDescription: string + codeLanguage: string + codeTokens: number + codeId: string + pageTitle: string + codeList: Array<{ + language: string + code: string + }> + relevance: number +} + +export interface ParsedQuery { + repositoryQuery: string + topicKeyword?: string + pageNumbers?: number[] +} + +export interface Context7MentionData { + content?: string + id?: string + isNavigation?: boolean + isError?: boolean + libraries?: SearchResult[] + topicKeyword?: string + [k: string]: unknown +} + +export interface Settings { + tokens: number + mentionLimit?: number +} + +export const DEFAULT_SETTINGS = { + mentionLimit: 5, +} as const + +export const SETTINGS_LIMITS = { + mentionLimit: { min: 1, max: 20 }, +} as const + +export const PATTERNS = { + PAGE_NUMBERS: /^\d+(?:\/\d+)*$/, +} as const diff --git a/provider/context7/vitest.config.js b/provider/context7/vitest.config.js new file mode 100644 index 00000000..abed6b21 --- /dev/null +++ b/provider/context7/vitest.config.js @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({})