doccupine 0.0.120 → 0.0.121
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.
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\nimport { rateLimit } from \"@/utils/rateLimit\";\nimport { config } from \"@/utils/config\";\n\nconst messageSchema = z.object({\n role: z.enum([\"user\", \"assistant\"]),\n content: z.string().max(4000),\n});\n\nconst ragSchema = z.object({\n question: z.string().min(1).max(2000),\n history: z.array(messageSchema).max(20).optional(),\n refresh: z.boolean().optional(),\n});\n\nconst projectName = config.name || \"Doccupine\";\n\nconst systemContext = `You are AI Assistant, a documentation assistant for ${projectName}, Your name is ${projectName} AI Assistant.\n\n## Core Rules\n1. Answer ONLY from the provided context. Never fabricate information.\n2. If the answer isn't in the context, say so clearly and suggest relevant sections or pages the user might check.\n3. If the question is ambiguous, ask a brief clarifying question before answering.\n\n## Response Style\n- Be concise and direct. Lead with the answer, then provide details if needed.\n- Use code examples from the context when relevant.\n- Match the technical level of the user's question.\n\n## MDX/Code Formatting\nWhen including code blocks in your response:\n- Never nest fenced code blocks (triple backticks) inside other fenced code blocks.\n- If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.\n- All output must be valid MDX that renders correctly.\n\n## Internal Links\nEach context chunk includes a \"URL:\" line with the pre-computed page URL. Use it directly when linking:\n- Format links as markdown: [Page Title](/slug/).\n- Never expose raw file paths like \"/app/.../page.tsx\" to the user.\n- Never include route group segments in parentheses, like \"(site)\", in any link - they are internal folder names and do not exist in real URLs. Use \"/code/\" not \"/(site)/code/\".\n- Do NOT add a \"Related Pages\" section at the end - sources are shown separately by the UI.\n\n## Greetings & Small Talk\nIf the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.`;\n\n// LangChain + the MCP SDK require the Node.js runtime (not edge).\nexport const runtime = \"nodejs\";\n// Safety net for the streaming function (Vercel-only; ignored elsewhere).\n// The heartbeat below, not this value, is what prevents proxy 524 timeouts.\nexport const maxDuration = 60;\n\nexport async function POST(req: Request) {\n // Rate limit by IP\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { error: \"Too many requests\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n // Validate the request up front so genuine client/config errors still return\n // real status codes before we commit to a 200 streaming response.\n let body: unknown;\n try {\n body = await req.json();\n } catch {\n return NextResponse.json({ error: \"Invalid JSON body\" }, { status: 400 });\n }\n\n const parsed = ragSchema.safeParse(body);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid input\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const { question, history, refresh } = parsed.data;\n\n let llmConfig;\n try {\n llmConfig = getLLMConfig();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"LLM configuration error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n\n const encoder = new TextEncoder();\n let heartbeat: ReturnType<typeof setInterval> | null = null;\n let streamClosed = false;\n\n // Return the streaming response immediately and do ALL slow work (indexing,\n // search, model streaming) inside start(). This flushes headers + a first\n // byte right away so edge proxies never hit their time-to-first-byte timeout.\n const readableStream = new ReadableStream({\n async start(controller) {\n const safeEnqueue = (payload: string) => {\n if (streamClosed) return;\n try {\n controller.enqueue(encoder.encode(payload));\n } catch {\n // Stream already closed or cancelled - stop emitting.\n streamClosed = true;\n if (heartbeat) clearInterval(heartbeat);\n }\n };\n\n // Keep the connection alive while the index and model warm up.\n heartbeat = setInterval(() => safeEnqueue(`: keep-alive\\n\\n`), 15000);\n\n try {\n // First byte, before any slow work - satisfies the proxy TTFB window.\n safeEnqueue(`: connected\\n\\n`);\n\n // Ensure docs are indexed (loads precomputed embeddings when present).\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(question, 6);\n\n // Build context from search results\n const context = searchResults\n .map(({ chunk, score }) => {\n const slug = chunk.uri.replace(\"docs://\", \"\").replace(/^\\/+/, \"\");\n const url = slug ? `/${slug}/` : \"/\";\n return `File: ${chunk.path}\\nURL: ${url}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`;\n })\n .join(\"\\n\\n================\\n\\n\");\n\n // Build metadata from MCP search results and send it before the answer.\n const indexStatus = getIndexStatus();\n const metadata = {\n sources: searchResults.map(({ chunk, score }) => ({\n id: chunk.id,\n path: chunk.path,\n uri: chunk.uri,\n score,\n })),\n chunkCount: indexStatus.chunkCount,\n };\n safeEnqueue(\n `data: ${JSON.stringify({ type: \"metadata\", data: metadata })}\\n\\n`,\n );\n\n // Assemble the prompt, including conversation history for multi-turn.\n const prompt: {\n role: \"system\" | \"user\" | \"assistant\";\n content: string;\n }[] = [\n {\n role: \"system\" as const,\n content: systemContext,\n },\n ];\n if (history && history.length > 0) {\n for (const msg of history) {\n prompt.push({\n role: msg.role,\n content: msg.content,\n });\n }\n }\n prompt.push({\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n });\n\n // Create chat model and stream the response.\n const llm = createChatModel(llmConfig);\n const stream = await llm.stream(prompt);\n for await (const chunk of stream) {\n const content = chunk?.content || \"\";\n if (content) {\n safeEnqueue(\n `data: ${JSON.stringify({ type: \"content\", data: content })}\\n\\n`,\n );\n }\n }\n\n safeEnqueue(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`);\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : \"Stream error\";\n safeEnqueue(\n `data: ${JSON.stringify({ type: \"error\", data: message })}\\n\\n`,\n );\n } finally {\n if (heartbeat) clearInterval(heartbeat);\n streamClosed = true;\n try {\n controller.close();\n } catch {\n // Already closed.\n }\n }\n },\n cancel() {\n streamClosed = true;\n if (heartbeat) clearInterval(heartbeat);\n },\n });\n\n return new Response(readableStream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache, no-transform\",\n },\n });\n}\n\nexport async function GET() {\n const status = getIndexStatus();\n return NextResponse.json({\n ready: status.ready,\n chunks: status.chunkCount,\n });\n}\n";
|
|
1
|
+
export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\nimport { rateLimit } from \"@/utils/rateLimit\";\nimport { config } from \"@/utils/config\";\n\nconst messageSchema = z.object({\n role: z.enum([\"user\", \"assistant\"]),\n content: z.string().max(4000),\n});\n\nconst ragSchema = z.object({\n question: z.string().min(1).max(2000),\n history: z.array(messageSchema).max(20).optional(),\n refresh: z.boolean().optional(),\n});\n\nconst projectName = config.name || \"Doccupine\";\n\nconst systemContext = `You are AI Assistant, a documentation assistant for ${projectName}, Your name is ${projectName} AI Assistant.\n\n## Core Rules\n1. Answer ONLY from the provided context. Never fabricate information.\n2. If the answer isn't in the context, say so clearly and suggest relevant sections or pages the user might check.\n3. If the question is ambiguous, ask a brief clarifying question before answering.\n\n## Response Style\n- Be concise and direct. Lead with the answer, then provide details if needed.\n- Use code examples from the context when relevant.\n- Match the technical level of the user's question.\n\n## MDX/Code Formatting\nWhen including code blocks in your response:\n- Never nest fenced code blocks (triple backticks) inside other fenced code blocks.\n- If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.\n- All output must be valid MDX that renders correctly.\n\n## Internal Links\nEach context chunk includes a \"URL:\" line with the pre-computed page URL. Use it directly when linking:\n- Format links as markdown: [Page Title](/slug/).\n- Never expose raw file paths like \"/app/.../page.tsx\" to the user.\n- Never include route group segments in parentheses, like \"(site)\", in any link - they are internal folder names and do not exist in real URLs. Use \"/code/\" not \"/(site)/code/\".\n- Do NOT add a \"Related Pages\" section at the end - sources are shown separately by the UI.\n\n## Greetings & Small Talk\nIf the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.`;\n\n// LangChain + the MCP SDK require the Node.js runtime (not edge).\nexport const runtime = \"nodejs\";\n// Safety net for the streaming function (Vercel-only; ignored elsewhere).\n// The heartbeat below, not this value, is what prevents proxy 524 timeouts.\nexport const maxDuration = 60;\n\nexport async function POST(req: Request) {\n // Rate limit by IP\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { error: \"Too many requests\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n // Validate the request up front so genuine client/config errors still return\n // real status codes before we commit to a 200 streaming response.\n let body: unknown;\n try {\n body = await req.json();\n } catch {\n return NextResponse.json({ error: \"Invalid JSON body\" }, { status: 400 });\n }\n\n const parsed = ragSchema.safeParse(body);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid input\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const { question, history, refresh } = parsed.data;\n\n let llmConfig;\n try {\n llmConfig = getLLMConfig();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"LLM configuration error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n\n const encoder = new TextEncoder();\n let heartbeat: ReturnType<typeof setInterval> | null = null;\n let streamClosed = false;\n\n // Return the streaming response immediately and do ALL slow work (indexing,\n // search, model streaming) inside start(). This flushes headers + a first\n // byte right away so edge proxies never hit their time-to-first-byte timeout.\n const readableStream = new ReadableStream({\n async start(controller) {\n const safeEnqueue = (payload: string) => {\n if (streamClosed) return;\n try {\n controller.enqueue(encoder.encode(payload));\n } catch {\n // Stream already closed or cancelled - stop emitting.\n streamClosed = true;\n if (heartbeat) clearInterval(heartbeat);\n }\n };\n\n // Keep the connection alive while the index and model warm up.\n heartbeat = setInterval(() => safeEnqueue(`: keep-alive\\n\\n`), 15000);\n\n try {\n // First byte, before any slow work - satisfies the proxy TTFB window.\n safeEnqueue(`: connected\\n\\n`);\n\n // Ensure docs are indexed (loads precomputed embeddings when present).\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(question, 6);\n\n // Build context from search results\n const context = searchResults\n .map(({ chunk, score }) => {\n const slug = chunk.uri.replace(\"docs://\", \"\").replace(/^\\/+/, \"\");\n const url = slug ? `/${slug}/` : \"/\";\n return `File: ${chunk.path}\\nURL: ${url}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`;\n })\n .join(\"\\n\\n================\\n\\n\");\n\n // Build metadata from MCP search results and send it before the answer.\n const indexStatus = getIndexStatus();\n const metadata = {\n sources: searchResults.map(({ chunk, score }) => ({\n id: chunk.id,\n path: chunk.path,\n uri: chunk.uri,\n score,\n })),\n chunkCount: indexStatus.chunkCount,\n };\n safeEnqueue(\n `data: ${JSON.stringify({ type: \"metadata\", data: metadata })}\\n\\n`,\n );\n\n // Assemble the prompt, including conversation history for multi-turn.\n const prompt: {\n role: \"system\" | \"user\" | \"assistant\";\n content: string;\n }[] = [\n {\n role: \"system\" as const,\n content: systemContext,\n },\n ];\n if (history && history.length > 0) {\n for (const msg of history) {\n prompt.push({\n role: msg.role,\n content: msg.content,\n });\n }\n }\n prompt.push({\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n });\n\n // Create chat model and stream the response.\n const llm = createChatModel(llmConfig);\n const stream = await llm.stream(prompt);\n for await (const chunk of stream) {\n const content = chunk?.content || \"\";\n if (content) {\n safeEnqueue(\n `data: ${JSON.stringify({ type: \"content\", data: content })}\\n\\n`,\n );\n }\n }\n\n safeEnqueue(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`);\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : \"Stream error\";\n safeEnqueue(\n `data: ${JSON.stringify({ type: \"error\", data: message })}\\n\\n`,\n );\n } finally {\n if (heartbeat) clearInterval(heartbeat);\n streamClosed = true;\n try {\n controller.close();\n } catch {\n // Already closed.\n }\n }\n },\n cancel() {\n streamClosed = true;\n if (heartbeat) clearInterval(heartbeat);\n },\n });\n\n return new Response(readableStream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache, no-transform\",\n },\n });\n}\n\nexport async function GET() {\n const status = getIndexStatus();\n return NextResponse.json({\n ready: status.ready,\n chunks: status.chunkCount,\n reason: status.reason,\n });\n}\n";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const mcpServerTemplate = "import path from \"node:path\";\nimport fs from \"node:fs\";\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, isLLMAvailable, createEmbeddings } from \"@/services/llm\";\nimport {\n reduceDims,\n quantizeInt8,\n decodeInt8,\n cosineFloatInt8,\n} from \"@/services/mcp/vector\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/** A doc chunk with its stored int8-quantized embedding. */\ntype IndexedChunk = DocsChunk & { embedding: Int8Array };\n\n/**\n * In-memory cache for document embeddings.\n * Built once at server startup since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: IndexedChunk[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\n\n/**\n * Absolute path to the embeddings index precomputed at build time by\n * scripts/build-docs-index.mts. Bundled into serverless functions via\n * outputFileTracingIncludes in next.config.ts.\n */\nconst INDEX_FILE = path.join(\n process.cwd(),\n \"services\",\n \"mcp\",\n \"docs-index.json\",\n);\n\n/**\n * Load embeddings precomputed at build time. Returns null when the file is\n * missing/empty, was built with a different provider/model/dimension than the\n * current config, or is not int8-quantized - query and document vectors must\n * come from the same embedding model and the same transform (dims + int8).\n * A null return makes the caller fall back to embedding on demand at runtime.\n */\nfunction loadPrecomputedIndex(): IndexedChunk[] | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(INDEX_FILE, \"utf8\")) as {\n provider?: string;\n embeddingModel?: string;\n dims?: number;\n quantization?: string;\n chunks?: (DocsChunk & { embedding: string })[];\n };\n if (!parsed.chunks || parsed.chunks.length === 0) return null;\n const config = getLLMConfig();\n // Guard on the exact transform: the same dims value at build and query time\n // guarantees reduceDims produces matching-length vectors on both sides.\n if (\n parsed.provider !== config.provider ||\n parsed.embeddingModel !== config.embeddingModel ||\n parsed.dims !== config.embeddingDims ||\n parsed.quantization !== \"int8\"\n ) {\n return null;\n }\n const decoded: IndexedChunk[] = [];\n let expectedLen = -1;\n for (const c of parsed.chunks) {\n const embedding = decodeInt8(c.embedding);\n // Reject a corrupt index rather than scoring against ragged vectors.\n if (expectedLen === -1) expectedLen = embedding.length;\n else if (embedding.length !== expectedLen) return null;\n decoded.push({\n id: c.id,\n text: c.text,\n path: c.path,\n uri: c.uri,\n embedding,\n });\n }\n return decoded;\n } catch {\n return null;\n }\n}\n\n/**\n * Build or rebuild the documentation index\n */\nasync function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n try {\n // Prefer embeddings precomputed at build time - avoids re-embedding the\n // entire doc set on every cold start (the main cause of slow first chats).\n if (!force) {\n const precomputed = loadPrecomputedIndex();\n if (precomputed) {\n docsIndex.chunks = precomputed;\n docsIndex.ready = true;\n return;\n }\n }\n\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits.\n // Reduce + quantize to the same int8 representation as the precomputed\n // index so searchDocs scores identically on either path.\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const built: IndexedChunk[] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n for (let j = 0; j < batchVectors.length; j++) {\n built.push({\n ...chunks[i + j],\n embedding: quantizeInt8(\n reduceDims(batchVectors[j], config.embeddingDims),\n ),\n });\n }\n }\n\n docsIndex.chunks = built;\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n // Wait for any in-flight build before starting a forced rebuild\n if (docsIndex.building && indexReady) {\n await indexReady.catch(() => {});\n }\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex(true);\n return indexReady;\n }\n if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n return indexReady;\n}\n\n// Eagerly start building the index on server startup if LLM is configured\nif (isLLMAvailable()) {\n indexReady = buildDocsIndex();\n}\n\n/** Cached embeddings instance for search queries */\nlet cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;\n\nfunction getEmbeddings() {\n if (!cachedEmbeddings) {\n cachedEmbeddings = createEmbeddings(getLLMConfig());\n }\n return cachedEmbeddings;\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex();\n\n // Reduce the query vector with the exact same transform used at index time so\n // dimensions line up. Scoring runs directly against the int8 vectors - cosine\n // is scale-invariant, so no dequantization is needed.\n const rawQueryVector = await getEmbeddings().embedQuery(query);\n const queryVector = reduceDims(rawQueryVector, getLLMConfig().embeddingDims);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineFloatInt8(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): { ready: boolean; chunkCount: number } {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.tool(\n \"search_docs\",\n DOCS_TOOLS[0].description,\n {\n query: z\n .string()\n .describe(\"The search query to find relevant documentation\"),\n limit: z\n .number()\n .optional()\n .describe(\"Maximum number of results to return (default: 6)\"),\n },\n async ({ query, limit }) => {\n const results = await searchDocs(query, limit ?? 6);\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register the get_doc tool\n server.tool(\n \"get_doc\",\n DOCS_TOOLS[1].description,\n {\n path: z.string().describe(\"The file path to the documentation page\"),\n },\n async ({ path }) => {\n const doc = await getDoc({ path });\n if (!doc) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: \"Document not found\" }),\n },\n ],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(doc, null, 2),\n },\n ],\n };\n },\n );\n\n // Register the list_docs tool\n server.tool(\n \"list_docs\",\n DOCS_TOOLS[2].description,\n {\n directory: z\n .string()\n .optional()\n .describe(\"Optional directory to filter results\"),\n },\n async ({ directory }) => {\n const docs = await listDocs({ directory });\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register documentation as resources\n server.resource(\"docs://list\", \"docs://list\", async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n text: JSON.stringify(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n null,\n 2,\n ),\n },\n ],\n };\n });\n\n return server;\n}\n";
|
|
1
|
+
export declare const mcpServerTemplate = "import path from \"node:path\";\nimport fs from \"node:fs\";\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, isLLMAvailable, createEmbeddings } from \"@/services/llm\";\nimport {\n reduceDims,\n quantizeInt8,\n decodeInt8,\n cosineFloatInt8,\n} from \"@/services/mcp/vector\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/** A doc chunk with its stored int8-quantized embedding. */\ntype IndexedChunk = DocsChunk & { embedding: Int8Array };\n\n/**\n * Thrown when a query arrives but no usable prebuilt embeddings index exists AND\n * the doc set is too large to embed within a single serverless request. Surfaced\n * to the client as a clear \"temporarily unavailable\" message instead of letting\n * the request hang until the platform's function timeout (the old failure mode:\n * the chat connected, streamed heartbeats, and never answered on large docs).\n */\nexport class IndexNotBuiltError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"IndexNotBuiltError\";\n }\n}\n\n/**\n * Max chunks we will embed on demand inside one request when the prebuilt index\n * is missing. Above this, embedding the whole set (one round trip per BATCH_SIZE\n * chunks) cannot finish within a serverless function's time limit, so the chat\n * would hang forever. Only enforced in production - `next dev` has no time cap,\n * so it keeps embedding on demand and works without running the build. Override\n * per deployment with RAG_RUNTIME_EMBED_MAX_CHUNKS (0 = always require a prebuilt\n * index). Parsed defensively so a bad value falls back to the default rather\n * than silently disabling the guard; note `Number(x) || 400` would drop a valid 0.\n */\nfunction resolveRuntimeEmbedMax(): number {\n const raw = process.env.RAG_RUNTIME_EMBED_MAX_CHUNKS;\n if (raw !== undefined && raw !== \"\") {\n const parsed = Number(raw);\n if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed);\n }\n return 400;\n}\nconst RUNTIME_EMBED_MAX_CHUNKS = resolveRuntimeEmbedMax();\n\n/**\n * In-memory cache for document embeddings.\n * Built once at server startup since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: IndexedChunk[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\n\n/**\n * Human-readable reason the index is unavailable, or null when healthy. Surfaced\n * by getIndexStatus (and GET /api/rag) so a missing/broken index is diagnosable\n * from outside without reading server logs.\n */\nlet indexUnavailableReason: string | null = null;\n\n/**\n * Absolute path to the embeddings index precomputed at build time by\n * scripts/build-docs-index.mts. Bundled into serverless functions via\n * outputFileTracingIncludes in next.config.ts.\n */\nconst INDEX_FILE = path.join(\n process.cwd(),\n \"services\",\n \"mcp\",\n \"docs-index.json\",\n);\n\n/**\n * Load embeddings precomputed at build time. Returns null when the file is\n * missing/empty, was built with a different provider/model/dimension than the\n * current config, or is not int8-quantized - query and document vectors must\n * come from the same embedding model and the same transform (dims + int8).\n * A null return makes the caller fall back to embedding on demand at runtime.\n */\nfunction loadPrecomputedIndex(): IndexedChunk[] | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(INDEX_FILE, \"utf8\")) as {\n provider?: string;\n embeddingModel?: string;\n dims?: number;\n quantization?: string;\n chunks?: (DocsChunk & { embedding: string })[];\n };\n if (!parsed.chunks || parsed.chunks.length === 0) return null;\n const config = getLLMConfig();\n // Guard on the exact transform: the same dims value at build and query time\n // guarantees reduceDims produces matching-length vectors on both sides.\n if (\n parsed.provider !== config.provider ||\n parsed.embeddingModel !== config.embeddingModel ||\n parsed.dims !== config.embeddingDims ||\n parsed.quantization !== \"int8\"\n ) {\n return null;\n }\n const decoded: IndexedChunk[] = [];\n let expectedLen = -1;\n for (const c of parsed.chunks) {\n const embedding = decodeInt8(c.embedding);\n // Reject a corrupt index rather than scoring against ragged vectors.\n if (expectedLen === -1) expectedLen = embedding.length;\n else if (embedding.length !== expectedLen) return null;\n decoded.push({\n id: c.id,\n text: c.text,\n path: c.path,\n uri: c.uri,\n embedding,\n });\n }\n return decoded;\n } catch {\n return null;\n }\n}\n\n/**\n * Build or rebuild the documentation index\n */\nasync function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n indexUnavailableReason = null;\n try {\n // Prefer embeddings precomputed at build time - avoids re-embedding the\n // entire doc set on every cold start (the main cause of slow first chats).\n if (!force) {\n const precomputed = loadPrecomputedIndex();\n if (precomputed) {\n docsIndex.chunks = precomputed;\n docsIndex.ready = true;\n return;\n }\n }\n\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n // Reaching here means no usable prebuilt index was loaded (missing, empty,\n // or built with a different provider/model/dims). Embedding the whole set on\n // demand only completes in time for small doc sets; for large ones it runs\n // one round trip per BATCH_SIZE chunks and blows past the serverless function\n // limit, so the chat connects and then hangs forever with no answer. In\n // production, fail fast with an actionable error instead of hanging (this\n // also stops a client-forced `refresh` from burning embedding quota).\n if (\n process.env.NODE_ENV === \"production\" &&\n chunks.length > RUNTIME_EMBED_MAX_CHUNKS\n ) {\n indexUnavailableReason =\n `Prebuilt embeddings index missing; refusing to embed ${chunks.length} ` +\n `chunks at request time (limit ${RUNTIME_EMBED_MAX_CHUNKS}). Run the build ` +\n `with an embedding API key set so scripts/build-docs-index.mts writes ` +\n `services/mcp/docs-index.json, then redeploy.`;\n console.error(`[doccupine] ${indexUnavailableReason}`);\n throw new IndexNotBuiltError(\n \"The AI assistant is temporarily unavailable: its documentation search \" +\n \"index has not been built for this deployment. If you are the site \" +\n \"owner, redeploy with an embedding API key available at build time.\",\n );\n }\n\n // Small enough (or local dev): embed on demand. This still re-embeds on every\n // cold start, so a prebuilt index is strongly preferred in production.\n console.warn(\n `[doccupine] No prebuilt embeddings index found; embedding ${chunks.length} ` +\n `chunks on demand. Precompute at build time to avoid this on each cold start.`,\n );\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits.\n // Reduce + quantize to the same int8 representation as the precomputed\n // index so searchDocs scores identically on either path.\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const built: IndexedChunk[] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n for (let j = 0; j < batchVectors.length; j++) {\n built.push({\n ...chunks[i + j],\n embedding: quantizeInt8(\n reduceDims(batchVectors[j], config.embeddingDims),\n ),\n });\n }\n }\n\n docsIndex.chunks = built;\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n // Wait for any in-flight build before starting a forced rebuild\n if (docsIndex.building && indexReady) {\n await indexReady.catch(() => {});\n }\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex(true);\n return indexReady;\n }\n if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n return indexReady;\n}\n\n// Eagerly start building the index on server startup if LLM is configured\nif (isLLMAvailable()) {\n const initialBuild = buildDocsIndex();\n indexReady = initialBuild;\n // A failed eager build (e.g. a missing prebuilt index on a large doc set) must\n // not surface as an unhandledRejection at startup; the first request retries\n // via ensureDocsIndex and returns a real error to the client.\n void initialBuild.catch(() => {});\n}\n\n/** Cached embeddings instance for search queries */\nlet cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;\n\nfunction getEmbeddings() {\n if (!cachedEmbeddings) {\n cachedEmbeddings = createEmbeddings(getLLMConfig());\n }\n return cachedEmbeddings;\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex();\n\n // Reduce the query vector with the exact same transform used at index time so\n // dimensions line up. Scoring runs directly against the int8 vectors - cosine\n // is scale-invariant, so no dequantization is needed.\n const rawQueryVector = await getEmbeddings().embedQuery(query);\n const queryVector = reduceDims(rawQueryVector, getLLMConfig().embeddingDims);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineFloatInt8(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): {\n ready: boolean;\n chunkCount: number;\n reason: string | null;\n} {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n reason: indexUnavailableReason,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.tool(\n \"search_docs\",\n DOCS_TOOLS[0].description,\n {\n query: z\n .string()\n .describe(\"The search query to find relevant documentation\"),\n limit: z\n .number()\n .optional()\n .describe(\"Maximum number of results to return (default: 6)\"),\n },\n async ({ query, limit }) => {\n const results = await searchDocs(query, limit ?? 6);\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register the get_doc tool\n server.tool(\n \"get_doc\",\n DOCS_TOOLS[1].description,\n {\n path: z.string().describe(\"The file path to the documentation page\"),\n },\n async ({ path }) => {\n const doc = await getDoc({ path });\n if (!doc) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: \"Document not found\" }),\n },\n ],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(doc, null, 2),\n },\n ],\n };\n },\n );\n\n // Register the list_docs tool\n server.tool(\n \"list_docs\",\n DOCS_TOOLS[2].description,\n {\n directory: z\n .string()\n .optional()\n .describe(\"Optional directory to filter results\"),\n },\n async ({ directory }) => {\n const docs = await listDocs({ directory });\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register documentation as resources\n server.resource(\"docs://list\", \"docs://list\", async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n text: JSON.stringify(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n null,\n 2,\n ),\n },\n ],\n };\n });\n\n return server;\n}\n";
|
|
@@ -20,6 +20,40 @@ import type { DocsChunk } from "@/services/mcp/types";
|
|
|
20
20
|
/** A doc chunk with its stored int8-quantized embedding. */
|
|
21
21
|
type IndexedChunk = DocsChunk & { embedding: Int8Array };
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Thrown when a query arrives but no usable prebuilt embeddings index exists AND
|
|
25
|
+
* the doc set is too large to embed within a single serverless request. Surfaced
|
|
26
|
+
* to the client as a clear "temporarily unavailable" message instead of letting
|
|
27
|
+
* the request hang until the platform's function timeout (the old failure mode:
|
|
28
|
+
* the chat connected, streamed heartbeats, and never answered on large docs).
|
|
29
|
+
*/
|
|
30
|
+
export class IndexNotBuiltError extends Error {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = "IndexNotBuiltError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Max chunks we will embed on demand inside one request when the prebuilt index
|
|
39
|
+
* is missing. Above this, embedding the whole set (one round trip per BATCH_SIZE
|
|
40
|
+
* chunks) cannot finish within a serverless function's time limit, so the chat
|
|
41
|
+
* would hang forever. Only enforced in production - \`next dev\` has no time cap,
|
|
42
|
+
* so it keeps embedding on demand and works without running the build. Override
|
|
43
|
+
* per deployment with RAG_RUNTIME_EMBED_MAX_CHUNKS (0 = always require a prebuilt
|
|
44
|
+
* index). Parsed defensively so a bad value falls back to the default rather
|
|
45
|
+
* than silently disabling the guard; note \`Number(x) || 400\` would drop a valid 0.
|
|
46
|
+
*/
|
|
47
|
+
function resolveRuntimeEmbedMax(): number {
|
|
48
|
+
const raw = process.env.RAG_RUNTIME_EMBED_MAX_CHUNKS;
|
|
49
|
+
if (raw !== undefined && raw !== "") {
|
|
50
|
+
const parsed = Number(raw);
|
|
51
|
+
if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed);
|
|
52
|
+
}
|
|
53
|
+
return 400;
|
|
54
|
+
}
|
|
55
|
+
const RUNTIME_EMBED_MAX_CHUNKS = resolveRuntimeEmbedMax();
|
|
56
|
+
|
|
23
57
|
/**
|
|
24
58
|
* In-memory cache for document embeddings.
|
|
25
59
|
* Built once at server startup since docs are static.
|
|
@@ -37,6 +71,13 @@ let docsIndex: {
|
|
|
37
71
|
/** Resolves when the initial index build completes */
|
|
38
72
|
let indexReady: Promise<void> | null = null;
|
|
39
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Human-readable reason the index is unavailable, or null when healthy. Surfaced
|
|
76
|
+
* by getIndexStatus (and GET /api/rag) so a missing/broken index is diagnosable
|
|
77
|
+
* from outside without reading server logs.
|
|
78
|
+
*/
|
|
79
|
+
let indexUnavailableReason: string | null = null;
|
|
80
|
+
|
|
40
81
|
/**
|
|
41
82
|
* Absolute path to the embeddings index precomputed at build time by
|
|
42
83
|
* scripts/build-docs-index.mts. Bundled into serverless functions via
|
|
@@ -106,6 +147,7 @@ async function buildDocsIndex(force = false): Promise<void> {
|
|
|
106
147
|
if (docsIndex.ready && !force) return;
|
|
107
148
|
|
|
108
149
|
docsIndex.building = true;
|
|
150
|
+
indexUnavailableReason = null;
|
|
109
151
|
try {
|
|
110
152
|
// Prefer embeddings precomputed at build time - avoids re-embedding the
|
|
111
153
|
// entire doc set on every cold start (the main cause of slow first chats).
|
|
@@ -126,6 +168,37 @@ async function buildDocsIndex(force = false): Promise<void> {
|
|
|
126
168
|
return;
|
|
127
169
|
}
|
|
128
170
|
|
|
171
|
+
// Reaching here means no usable prebuilt index was loaded (missing, empty,
|
|
172
|
+
// or built with a different provider/model/dims). Embedding the whole set on
|
|
173
|
+
// demand only completes in time for small doc sets; for large ones it runs
|
|
174
|
+
// one round trip per BATCH_SIZE chunks and blows past the serverless function
|
|
175
|
+
// limit, so the chat connects and then hangs forever with no answer. In
|
|
176
|
+
// production, fail fast with an actionable error instead of hanging (this
|
|
177
|
+
// also stops a client-forced \`refresh\` from burning embedding quota).
|
|
178
|
+
if (
|
|
179
|
+
process.env.NODE_ENV === "production" &&
|
|
180
|
+
chunks.length > RUNTIME_EMBED_MAX_CHUNKS
|
|
181
|
+
) {
|
|
182
|
+
indexUnavailableReason =
|
|
183
|
+
\`Prebuilt embeddings index missing; refusing to embed \${chunks.length} \` +
|
|
184
|
+
\`chunks at request time (limit \${RUNTIME_EMBED_MAX_CHUNKS}). Run the build \` +
|
|
185
|
+
\`with an embedding API key set so scripts/build-docs-index.mts writes \` +
|
|
186
|
+
\`services/mcp/docs-index.json, then redeploy.\`;
|
|
187
|
+
console.error(\`[doccupine] \${indexUnavailableReason}\`);
|
|
188
|
+
throw new IndexNotBuiltError(
|
|
189
|
+
"The AI assistant is temporarily unavailable: its documentation search " +
|
|
190
|
+
"index has not been built for this deployment. If you are the site " +
|
|
191
|
+
"owner, redeploy with an embedding API key available at build time.",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Small enough (or local dev): embed on demand. This still re-embeds on every
|
|
196
|
+
// cold start, so a prebuilt index is strongly preferred in production.
|
|
197
|
+
console.warn(
|
|
198
|
+
\`[doccupine] No prebuilt embeddings index found; embedding \${chunks.length} \` +
|
|
199
|
+
\`chunks on demand. Precompute at build time to avoid this on each cold start.\`,
|
|
200
|
+
);
|
|
201
|
+
|
|
129
202
|
const config = getLLMConfig();
|
|
130
203
|
const embeddings = createEmbeddings(config);
|
|
131
204
|
|
|
@@ -183,7 +256,12 @@ export async function ensureDocsIndex(force = false): Promise<void> {
|
|
|
183
256
|
|
|
184
257
|
// Eagerly start building the index on server startup if LLM is configured
|
|
185
258
|
if (isLLMAvailable()) {
|
|
186
|
-
|
|
259
|
+
const initialBuild = buildDocsIndex();
|
|
260
|
+
indexReady = initialBuild;
|
|
261
|
+
// A failed eager build (e.g. a missing prebuilt index on a large doc set) must
|
|
262
|
+
// not surface as an unhandledRejection at startup; the first request retries
|
|
263
|
+
// via ensureDocsIndex and returns a real error to the client.
|
|
264
|
+
void initialBuild.catch(() => {});
|
|
187
265
|
}
|
|
188
266
|
|
|
189
267
|
/** Cached embeddings instance for search queries */
|
|
@@ -225,10 +303,15 @@ export async function searchDocs(
|
|
|
225
303
|
/**
|
|
226
304
|
* Get the current index status
|
|
227
305
|
*/
|
|
228
|
-
export function getIndexStatus(): {
|
|
306
|
+
export function getIndexStatus(): {
|
|
307
|
+
ready: boolean;
|
|
308
|
+
chunkCount: number;
|
|
309
|
+
reason: string | null;
|
|
310
|
+
} {
|
|
229
311
|
return {
|
|
230
312
|
ready: docsIndex.ready,
|
|
231
313
|
chunkCount: docsIndex.chunks.length,
|
|
314
|
+
reason: indexUnavailableReason,
|
|
232
315
|
};
|
|
233
316
|
}
|
|
234
317
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doccupine",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.121",
|
|
4
4
|
"description": "Free and open-source documentation platform. Write MDX, get a production-ready site with AI chat, built-in components, and an MCP server - in one command.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|