doccupine 0.0.39 → 0.0.41

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 path from \"node:path\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\n\nconst PROJECT_ROOT = process.cwd();\n\nexport async function POST(req: Request) {\n try {\n const { question, refresh } = await req.json();\n\n let config;\n try {\n config = 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 // Use MCP service to ensure docs are indexed\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(String(question || \"\"), 6);\n\n // Build context from search results\n const context = searchResults\n .map(\n ({ chunk, score }) =>\n `File: ${path.relative(PROJECT_ROOT, chunk.path)}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`,\n )\n .join(\"\\n\\n================\\n\\n\");\n\n // Create chat model and stream response\n const llm = createChatModel(config);\n const prompt = [\n {\n role: \"system\" as const,\n content:\n \"You are a helpful documentation assistant. Answer strictly from the provided context. If the answer is not in the context, say you don't know and suggest where to look. Make sure the mdx can be nested properly if you show code components within nested ```\",\n },\n {\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n },\n ];\n\n const stream = await llm.stream(prompt);\n\n // Build metadata from MCP search results\n const indexStatus = getIndexStatus();\n const metadata = {\n sources: searchResults.map(({ chunk, score }) => ({\n id: chunk.id,\n path: path.relative(PROJECT_ROOT, chunk.path),\n uri: chunk.uri,\n score,\n })),\n chunkCount: indexStatus.chunkCount,\n };\n\n const encoder = new TextEncoder();\n const readableStream = new ReadableStream({\n async start(controller) {\n try {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"metadata\", data: metadata })}\\n\\n`,\n ),\n );\n\n for await (const chunk of stream) {\n const content = chunk?.content || \"\";\n if (content) {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"content\", data: content })}\\n\\n`,\n ),\n );\n }\n }\n\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`),\n );\n controller.close();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"Stream error\";\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", data: message })}\\n\\n`,\n ),\n );\n controller.close();\n }\n },\n });\n\n return new Response(readableStream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n },\n });\n } catch (e: unknown) {\n const message = e instanceof Error ? e.message : \"Unknown error\";\n return NextResponse.json({ error: message }, { status: 500 });\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 path from \"node:path\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\n\nconst PROJECT_ROOT = process.cwd();\n\nexport async function POST(req: Request) {\n try {\n const { question, refresh } = await req.json();\n\n let config;\n try {\n config = 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 // Use MCP service to ensure docs are indexed\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(String(question || \"\"), 6);\n\n // Build context from search results\n const context = searchResults\n .map(\n ({ chunk, score }) =>\n `File: ${path.relative(PROJECT_ROOT, chunk.path)}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`,\n )\n .join(\"\\n\\n================\\n\\n\");\n\n // Create chat model and stream response\n const llm = createChatModel(config);\n const prompt = [\n {\n role: \"system\" as const,\n content:\n \"You are a helpful documentation assistant. Answer strictly from the provided context. If the answer is not in the context, say you don't know and suggest where to look. Make sure the mdx can be nested properly if you show code components within nested ``` it has to be valid md/mdx output.\",\n },\n {\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n },\n ];\n\n const stream = await llm.stream(prompt);\n\n // Build metadata from MCP search results\n const indexStatus = getIndexStatus();\n const metadata = {\n sources: searchResults.map(({ chunk, score }) => ({\n id: chunk.id,\n path: path.relative(PROJECT_ROOT, chunk.path),\n uri: chunk.uri,\n score,\n })),\n chunkCount: indexStatus.chunkCount,\n };\n\n const encoder = new TextEncoder();\n const readableStream = new ReadableStream({\n async start(controller) {\n try {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"metadata\", data: metadata })}\\n\\n`,\n ),\n );\n\n for await (const chunk of stream) {\n const content = chunk?.content || \"\";\n if (content) {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"content\", data: content })}\\n\\n`,\n ),\n );\n }\n }\n\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`),\n );\n controller.close();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"Stream error\";\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", data: message })}\\n\\n`,\n ),\n );\n controller.close();\n }\n },\n });\n\n return new Response(readableStream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n },\n });\n } catch (e: unknown) {\n const message = e instanceof Error ? e.message : \"Unknown error\";\n return NextResponse.json({ error: message }, { status: 500 });\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";
@@ -42,7 +42,7 @@ export async function POST(req: Request) {
42
42
  {
43
43
  role: "system" as const,
44
44
  content:
45
- "You are a helpful documentation assistant. Answer strictly from the provided context. If the answer is not in the context, say you don't know and suggest where to look. Make sure the mdx can be nested properly if you show code components within nested \`\`\`",
45
+ "You are a helpful documentation assistant. Answer strictly from the provided context. If the answer is not in the context, say you don't know and suggest where to look. Make sure the mdx can be nested properly if you show code components within nested \`\`\` it has to be valid md/mdx output.",
46
46
  },
47
47
  {
48
48
  role: "user" as const,
@@ -1 +1 @@
1
- export declare const docsNavigationTemplate = "\"use client\";\nimport { useContext } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport Link from \"next/link\";\nimport styled, { css } from \"styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\nimport { ChatContext } from \"@/components/Chat\";\nconst StyledNavigationWrapper = styled.div<{\n $isChatOpen?: boolean;\n}>`\n transition: all 0.3s ease;\n padding: 0 20px 100px 20px;\n ${mq(\"lg\")} {\n padding: 0 340px 80px 340px;\n ${({ $isChatOpen }) =>\n $isChatOpen &&\n css`\n padding: 0 440px 80px 340px;\n `}\n }\n`;\nconst StyledNavigationInner = styled.div`\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 20px;\n max-width: 640px;\n margin: auto;\n`;\nconst StyledNavButton = styled(Link)<{ theme: Theme }>`\n ${interactiveStyles};\n display: flex;\n flex-direction: column;\n text-decoration: none;\n padding: 20px;\n flex: 50%;\n max-width: 50%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.dark};\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n &[data-direction=\"prev\"] {\n align-items: flex-start;\n }\n &[data-direction=\"next\"] {\n align-items: flex-end;\n margin-left: auto;\n text-align: right;\n }\n`;\nconst StyledNavLabel = styled.span<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.gray};\n display: flex;\n flex-direction: row;\n gap: 4px;\n & svg {\n margin: auto 0;\n }\n`;\nconst StyledNavTitle = styled.span<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.dark};\n font-weight: 600;\n margin: 0 0 4px 0;\n white-space: nowrap;\n text-overflow: ellipsis;\n overflow: hidden;\n`;\nconst StyledSpacer = styled.div`\n flex: 1;\n`;\ninterface Page {\n slug: string;\n title: string;\n category?: string;\n [key: string]: any;\n}\ninterface NavigationItem {\n category?: string;\n slug?: string;\n title?: string;\n links?: Page[];\n items?: Page[];\n [key: string]: any;\n}\ninterface DocsNavigationProps {\n result: NavigationItem[];\n}\nfunction DocsNavigation({ result }: DocsNavigationProps) {\n const { isOpen } = useContext(ChatContext);\n const pathname = usePathname();\n const allPages: Page[] = result.flatMap((item) => {\n if (item.links && Array.isArray(item.links)) {\n return item.links;\n }\n if (item.items && Array.isArray(item.items)) {\n return item.items;\n }\n if (item.slug !== undefined) {\n return [item as Page];\n }\n return [];\n });\n const currentSlug = pathname.replace(/^\\//, \"\").replace(/\\/$/, \"\");\n const currentIndex = allPages.findIndex((page) => page.slug === currentSlug);\n const prevPage = currentIndex > 0 ? allPages[currentIndex - 1] : null;\n const nextPage =\n currentIndex < allPages.length - 1 ? allPages[currentIndex + 1] : null;\n if (currentIndex === -1 || allPages.length === 0) {\n return null;\n }\n if (!prevPage && !nextPage) {\n return null;\n }\n return (\n <StyledNavigationWrapper $isChatOpen={isOpen}>\n <StyledNavigationInner>\n {prevPage ? (\n <StyledNavButton href={`/${prevPage.slug}`} data-direction=\"prev\">\n <StyledNavTitle>{prevPage.title}</StyledNavTitle>\n <StyledNavLabel>\n <Icon name=\"arrow-left\" size={16} /> Previous\n </StyledNavLabel>\n </StyledNavButton>\n ) : (\n <StyledSpacer />\n )}\n {nextPage && (\n <StyledNavButton href={`/${nextPage.slug}`} data-direction=\"next\">\n <StyledNavTitle>{nextPage.title}</StyledNavTitle>\n <StyledNavLabel>\n Next <Icon name=\"arrow-right\" size={16} />\n </StyledNavLabel>\n </StyledNavButton>\n )}\n </StyledNavigationInner>\n </StyledNavigationWrapper>\n );\n}\nexport { DocsNavigation };\n";
1
+ export declare const docsNavigationTemplate = "\"use client\";\nimport { useContext } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport Link from \"next/link\";\nimport styled, { css } from \"styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\nimport { ChatContext } from \"@/components/Chat\";\n\nconst StyledNavigationWrapper = styled.div<{\n $isChatOpen?: boolean;\n}>`\n transition: all 0.3s ease;\n padding: 0 20px 100px 20px;\n ${mq(\"lg\")} {\n padding: 0 340px 80px 340px;\n ${({ $isChatOpen }) =>\n $isChatOpen &&\n css`\n padding: 0 440px 80px 340px;\n `}\n }\n`;\n\nconst StyledNavigationInner = styled.div`\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 20px;\n max-width: 640px;\n margin: auto;\n`;\n\nconst StyledNavButton = styled(Link)<{ theme: Theme }>`\n ${interactiveStyles};\n display: flex;\n flex-direction: column;\n text-decoration: none;\n padding: 20px;\n flex: 50%;\n max-width: 50%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.dark};\n \n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n &[data-direction=\"prev\"] {\n align-items: flex-start;\n }\n\n &[data-direction=\"next\"] {\n align-items: flex-end;\n margin-left: auto;\n text-align: right;\n }\n`;\n\nconst StyledNavLabel = styled.span<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.gray};\n display: flex;\n flex-direction: row;\n gap: 4px;\n\n & svg {\n margin: auto 0;\n }\n`;\n\nconst StyledNavTitle = styled.span<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.dark};\n font-weight: 600;\n margin: 0 0 4px 0;\n white-space: nowrap;\n text-overflow: ellipsis;\n overflow: hidden;\n max-width: 100%;\n`;\n\nconst StyledSpacer = styled.div`\n flex: 1;\n`;\n\ninterface Page {\n slug: string;\n title: string;\n category?: string;\n [key: string]: any;\n}\n\ninterface NavigationItem {\n category?: string;\n slug?: string;\n title?: string;\n links?: Page[];\n items?: Page[];\n [key: string]: any;\n}\n\ninterface DocsNavigationProps {\n result: NavigationItem[];\n}\n\nfunction DocsNavigation({ result }: DocsNavigationProps) {\n const { isOpen } = useContext(ChatContext);\n const pathname = usePathname();\n const allPages: Page[] = result.flatMap((item) => {\n if (item.links && Array.isArray(item.links)) {\n return item.links;\n }\n if (item.items && Array.isArray(item.items)) {\n return item.items;\n }\n if (item.slug !== undefined) {\n return [item as Page];\n }\n return [];\n });\n const currentSlug = pathname.replace(/^\\//, \"\").replace(/\\/$/, \"\");\n const currentIndex = allPages.findIndex((page) => page.slug === currentSlug);\n const prevPage = currentIndex > 0 ? allPages[currentIndex - 1] : null;\n const nextPage =\n currentIndex < allPages.length - 1 ? allPages[currentIndex + 1] : null;\n if (currentIndex === -1 || allPages.length === 0) {\n return null;\n }\n if (!prevPage && !nextPage) {\n return null;\n }\n return (\n <StyledNavigationWrapper $isChatOpen={isOpen}>\n <StyledNavigationInner>\n {prevPage ? (\n <StyledNavButton href={`/${prevPage.slug}`} data-direction=\"prev\">\n <StyledNavTitle>{prevPage.title}</StyledNavTitle>\n <StyledNavLabel>\n <Icon name=\"arrow-left\" size={16} /> Previous\n </StyledNavLabel>\n </StyledNavButton>\n ) : (\n <StyledSpacer />\n )}\n {nextPage && (\n <StyledNavButton href={`/${nextPage.slug}`} data-direction=\"next\">\n <StyledNavTitle>{nextPage.title}</StyledNavTitle>\n <StyledNavLabel>\n Next <Icon name=\"arrow-right\" size={16} />\n </StyledNavLabel>\n </StyledNavButton>\n )}\n </StyledNavigationInner>\n </StyledNavigationWrapper>\n );\n}\n\nexport { DocsNavigation };\n";
@@ -7,6 +7,7 @@ import { Icon } from "@/components/layout/Icon";
7
7
  import { mq, Theme } from "@/app/theme";
8
8
  import { interactiveStyles } from "@/components/layout/SharedStyled";
9
9
  import { ChatContext } from "@/components/Chat";
10
+
10
11
  const StyledNavigationWrapper = styled.div<{
11
12
  $isChatOpen?: boolean;
12
13
  }>\`
@@ -21,6 +22,7 @@ const StyledNavigationWrapper = styled.div<{
21
22
  \`}
22
23
  }
23
24
  \`;
25
+
24
26
  const StyledNavigationInner = styled.div\`
25
27
  display: flex;
26
28
  justify-content: space-between;
@@ -29,6 +31,7 @@ const StyledNavigationInner = styled.div\`
29
31
  max-width: 640px;
30
32
  margin: auto;
31
33
  \`;
34
+
32
35
  const StyledNavButton = styled(Link)<{ theme: Theme }>\`
33
36
  \${interactiveStyles};
34
37
  display: flex;
@@ -40,27 +43,33 @@ const StyledNavButton = styled(Link)<{ theme: Theme }>\`
40
43
  border-radius: \${({ theme }) => theme.spacing.radius.lg};
41
44
  border: solid 1px \${({ theme }) => theme.colors.grayLight};
42
45
  color: \${({ theme }) => theme.colors.dark};
46
+
43
47
  &:hover {
44
48
  border-color: \${({ theme }) => theme.colors.primary};
45
49
  }
50
+
46
51
  &[data-direction="prev"] {
47
52
  align-items: flex-start;
48
53
  }
54
+
49
55
  &[data-direction="next"] {
50
56
  align-items: flex-end;
51
57
  margin-left: auto;
52
58
  text-align: right;
53
59
  }
54
60
  \`;
61
+
55
62
  const StyledNavLabel = styled.span<{ theme: Theme }>\`
56
63
  color: \${({ theme }) => theme.colors.gray};
57
64
  display: flex;
58
65
  flex-direction: row;
59
66
  gap: 4px;
67
+
60
68
  & svg {
61
69
  margin: auto 0;
62
70
  }
63
71
  \`;
72
+
64
73
  const StyledNavTitle = styled.span<{ theme: Theme }>\`
65
74
  color: \${({ theme }) => theme.colors.dark};
66
75
  font-weight: 600;
@@ -68,16 +77,20 @@ const StyledNavTitle = styled.span<{ theme: Theme }>\`
68
77
  white-space: nowrap;
69
78
  text-overflow: ellipsis;
70
79
  overflow: hidden;
80
+ max-width: 100%;
71
81
  \`;
82
+
72
83
  const StyledSpacer = styled.div\`
73
84
  flex: 1;
74
85
  \`;
86
+
75
87
  interface Page {
76
88
  slug: string;
77
89
  title: string;
78
90
  category?: string;
79
91
  [key: string]: any;
80
92
  }
93
+
81
94
  interface NavigationItem {
82
95
  category?: string;
83
96
  slug?: string;
@@ -86,9 +99,11 @@ interface NavigationItem {
86
99
  items?: Page[];
87
100
  [key: string]: any;
88
101
  }
102
+
89
103
  interface DocsNavigationProps {
90
104
  result: NavigationItem[];
91
105
  }
106
+
92
107
  function DocsNavigation({ result }: DocsNavigationProps) {
93
108
  const { isOpen } = useContext(ChatContext);
94
109
  const pathname = usePathname();
@@ -140,5 +155,6 @@ function DocsNavigation({ result }: DocsNavigationProps) {
140
155
  </StyledNavigationWrapper>
141
156
  );
142
157
  }
158
+
143
159
  export { DocsNavigation };
144
160
  `;
@@ -1 +1 @@
1
- export declare const envExampleTemplate = "# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default models\n# OpenAI models: gpt-4.1-mini, gpt-4.1-nano, gpt-4.1\n# Anthropic models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-5-20251101\n# Google models: gemini-2.5-flash-lite, gemini-2.5-pro, gemini-2.5-flash\n# LLM_CHAT_MODEL=gpt-4.1-nano\n\n# Optional: Override default embedding model\n# OpenAI: text-embedding-3-small, text-embedding-3-large\n# Google: text-embedding-004\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=text-embedding-3-small\n\n# Optional: Set temperature (0-1, default: 0)\n# LLM_TEMPERATURE=0\n";
1
+ export declare const envExampleTemplate = "# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default models\n# OpenAI models: gpt-5-mini, gpt-5-nano, gpt-5\n# Anthropic models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-5-20251101\n# Google models: gemini-2.5-flash-lite, gemini-2.5-pro, gemini-2.5-flash\n# LLM_CHAT_MODEL=gpt-4.1-nano\n\n# Optional: Override default embedding model\n# OpenAI: text-embedding-3-small, text-embedding-3-large\n# Google: text-embedding-004\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=text-embedding-3-small\n\n# Optional: Set temperature (0-1, default: 1)\n# LLM_TEMPERATURE=1\n";
@@ -8,7 +8,7 @@ ANTHROPIC_API_KEY=your_anthropic_api_key_here
8
8
  GOOGLE_API_KEY=your_google_api_key_here
9
9
 
10
10
  # Optional: Override default models
11
- # OpenAI models: gpt-4.1-mini, gpt-4.1-nano, gpt-4.1
11
+ # OpenAI models: gpt-5-mini, gpt-5-nano, gpt-5
12
12
  # Anthropic models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-5-20251101
13
13
  # Google models: gemini-2.5-flash-lite, gemini-2.5-pro, gemini-2.5-flash
14
14
  # LLM_CHAT_MODEL=gpt-4.1-nano
@@ -19,6 +19,6 @@ GOOGLE_API_KEY=your_google_api_key_here
19
19
  # Note: Anthropic doesn't provide embeddings, will fallback to OpenAI
20
20
  # LLM_EMBEDDING_MODEL=text-embedding-3-small
21
21
 
22
- # Optional: Set temperature (0-1, default: 0)
23
- # LLM_TEMPERATURE=0
22
+ # Optional: Set temperature (0-1, default: 1)
23
+ # LLM_TEMPERATURE=1
24
24
  `;
@@ -1 +1 @@
1
- export declare const aiAssistantMdxTemplate = "---\ntitle: \"AI Assistant\"\ndescription: \"Integrate AI capabilities into your Doccupine documentation using OpenAI, Anthropic, or Google Gemini.\"\ndate: \"2025-01-24\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 7\n---\n# AI Assistant\nDoccupine supports AI integration to enhance your documentation experience. You can use OpenAI, Anthropic, or Google Gemini to power AI features in your documentation site. The AI assistant uses your documentation content as context, allowing users to ask questions about your docs and receive accurate answers based on the documentation.\n\n## Setup\nTo enable AI features, create an `.env` file in the directory where your website is generated. By default, this is the `nextjs-app/` directory.\n\n## Configuration\nCreate an `.env` file with the following configuration options:\n\n```env\n# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default models\n# OpenAI models: gpt-4.1-mini, gpt-4.1-nano, gpt-4.1\n# Anthropic models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-5-20251101\n# Google models: gemini-2.5-flash-lite, gemini-2.5-pro, gemini-2.5-flash\n# LLM_CHAT_MODEL=gpt-4.1-nano\n\n# Optional: Override default embedding model\n# OpenAI: text-embedding-3-small, text-embedding-3-large\n# Google: text-embedding-004\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=text-embedding-3-small\n\n# Optional: Set temperature (0-1, default: 0)\n# LLM_TEMPERATURE=0\n```\n\n## Provider Selection\nSet `LLM_PROVIDER` to one of the following values:\n- `openai` - Use OpenAI's models (GPT-4.1, GPT-4.1-mini, GPT-4.1-nano)\n- `anthropic` - Use Anthropic's models (Claude Sonnet 4.5, Claude Haiku 4.5, Claude Opus 4.5)\n- `google` - Use Google's models (Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash-Lite)\n\n## API Keys\nYou need to set the API key that matches your chosen provider:\n- For OpenAI: Set `OPENAI_API_KEY`\n- For Anthropic: Set `ANTHROPIC_API_KEY`\n- For Google: Set `GOOGLE_API_KEY`\n\n<Callout type=\"warning\">\n Keep your API keys secure. Never commit your `.env` file to version control.\n</Callout>\n\n<Callout type=\"note\">\n Doccupine automatically adds `.env` to your `.gitignore` file.\n</Callout>\n\n## Using Anthropic with OpenAI\nIf you want to use Anthropic as your LLM provider, you must also have an OpenAI API key set. Here's why:\n\n### The Situation\nAnthropic (Claude) does not provide an embeddings API. They only offer chat/completion models, not text embeddings.\n\nYour RAG (Retrieval-Augmented Generation) system has two components:\n- **Chat/Completion** - Generates answers, works with Anthropic.\n- **Embeddings** - Creates vector representations of text for search, Anthropic doesn't provide this.\n\nWhen using Anthropic as your `LLM_PROVIDER`, Doccupine will use Anthropic for chat/completion tasks, but will automatically fallback to OpenAI for embeddings. This means you need both API keys configured:\n\n```env\nLLM_PROVIDER=anthropic\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nOPENAI_API_KEY=your_openai_api_key_here\n```\n\nThis hybrid approach allows you to leverage Anthropic's powerful chat models while still having access to embeddings functionality through OpenAI.\n\n## Optional Settings\n\n### Chat Model\nOverride the default chat model by uncommenting and setting `LLM_CHAT_MODEL`. You can use any available model from your chosen provider. Example models include:\n- **OpenAI**: `gpt-4.1-nano`, `gpt-4.1-mini`, `gpt-4.1`\n- **Anthropic**: `claude-sonnet-4-5-20250929`, `claude-haiku-4-5-20251001`, `claude-opus-4-5-20251101`\n- **Google**: `gemini-2.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash`\n\nFor a complete list of available models, refer to the official documentation:\n- [OpenAI Models](https://platform.openai.com/docs/models)\n- [Anthropic Models](https://docs.anthropic.com/claude/docs/models-overview)\n- [Google Gemini Models](https://ai.google.dev/models/gemini)\n\n### Embedding Model\nOverride the default embedding model by uncommenting and setting `LLM_EMBEDDING_MODEL`:\n- **OpenAI**: `text-embedding-3-small`, `text-embedding-3-large`\n- **Google**: `text-embedding-004`\n- **Anthropic**: Anthropic doesn't provide embeddings. If you use Anthropic as your provider, Doccupine will fallback to OpenAI for embeddings.\n\n### Temperature\nControl the randomness of AI responses by setting `LLM_TEMPERATURE` to a value between 0 and 1:\n- `0` - More deterministic and focused responses (default)\n- `1` - More creative and varied responses";
1
+ export declare const aiAssistantMdxTemplate = "---\ntitle: \"AI Assistant\"\ndescription: \"Integrate AI capabilities into your Doccupine documentation using OpenAI, Anthropic, or Google Gemini.\"\ndate: \"2025-01-24\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 7\n---\n# AI Assistant\nDoccupine supports AI integration to enhance your documentation experience. You can use OpenAI, Anthropic, or Google Gemini to power AI features in your documentation site. The AI assistant uses your documentation content as context, allowing users to ask questions about your docs and receive accurate answers based on the documentation.\n\n## Setup\nTo enable AI features, create an `.env` file in the directory where your website is generated. By default, this is the `nextjs-app/` directory.\n\n## Configuration\nCreate an `.env` file with the following configuration options:\n\n```env\n# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default models\n# OpenAI models: gpt-5-mini, gpt-5-nano, gpt-5\n# Anthropic models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-5-20251101\n# Google models: gemini-2.5-flash-lite, gemini-2.5-pro, gemini-2.5-flash\n# LLM_CHAT_MODEL=gpt-4.1-nano\n\n# Optional: Override default embedding model\n# OpenAI: text-embedding-3-small, text-embedding-3-large\n# Google: text-embedding-004\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=text-embedding-3-small\n\n# Optional: Set temperature (0-1, default: 1)\n# LLM_TEMPERATURE=1\n```\n\n## Provider Selection\nSet `LLM_PROVIDER` to one of the following values:\n- `openai` - Use OpenAI's models (GPT-4.1, GPT-4.1-mini, GPT-4.1-nano)\n- `anthropic` - Use Anthropic's models (Claude Sonnet 4.5, Claude Haiku 4.5, Claude Opus 4.5)\n- `google` - Use Google's models (Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash-Lite)\n\n## API Keys\nYou need to set the API key that matches your chosen provider:\n- For OpenAI: Set `OPENAI_API_KEY`\n- For Anthropic: Set `ANTHROPIC_API_KEY`\n- For Google: Set `GOOGLE_API_KEY`\n\n<Callout type=\"warning\">\n Keep your API keys secure. Never commit your `.env` file to version control.\n</Callout>\n\n<Callout type=\"note\">\n Doccupine automatically adds `.env` to your `.gitignore` file.\n</Callout>\n\n## Using Anthropic with OpenAI\nIf you want to use Anthropic as your LLM provider, you must also have an OpenAI API key set. Here's why:\n\n### The Situation\nAnthropic (Claude) does not provide an embeddings API. They only offer chat/completion models, not text embeddings.\n\nYour RAG (Retrieval-Augmented Generation) system has two components:\n- **Chat/Completion** - Generates answers, works with Anthropic.\n- **Embeddings** - Creates vector representations of text for search, Anthropic doesn't provide this.\n\nWhen using Anthropic as your `LLM_PROVIDER`, Doccupine will use Anthropic for chat/completion tasks, but will automatically fallback to OpenAI for embeddings. This means you need both API keys configured:\n\n```env\nLLM_PROVIDER=anthropic\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nOPENAI_API_KEY=your_openai_api_key_here\n```\n\nThis hybrid approach allows you to leverage Anthropic's powerful chat models while still having access to embeddings functionality through OpenAI.\n\n## Optional Settings\n\n### Chat Model\nOverride the default chat model by uncommenting and setting `LLM_CHAT_MODEL`. You can use any available model from your chosen provider. Example models include:\n- **OpenAI**: `gpt-4.1-nano`, `gpt-4.1-mini`, `gpt-4.1`\n- **Anthropic**: `claude-sonnet-4-5-20250929`, `claude-haiku-4-5-20251001`, `claude-opus-4-5-20251101`\n- **Google**: `gemini-2.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash`\n\nFor a complete list of available models, refer to the official documentation:\n- [OpenAI Models](https://platform.openai.com/docs/models)\n- [Anthropic Models](https://docs.anthropic.com/claude/docs/models-overview)\n- [Google Gemini Models](https://ai.google.dev/models/gemini)\n\n### Embedding Model\nOverride the default embedding model by uncommenting and setting `LLM_EMBEDDING_MODEL`:\n- **OpenAI**: `text-embedding-3-small`, `text-embedding-3-large`\n- **Google**: `text-embedding-004`\n- **Anthropic**: Anthropic doesn't provide embeddings. If you use Anthropic as your provider, Doccupine will fallback to OpenAI for embeddings.\n\n### Temperature\nControl the randomness of AI responses by setting `LLM_TEMPERATURE` to a value between 0 and 1:\n- `0` - More deterministic and focused responses (default)\n- `1` - More creative and varied responses";
@@ -26,7 +26,7 @@ ANTHROPIC_API_KEY=your_anthropic_api_key_here
26
26
  GOOGLE_API_KEY=your_google_api_key_here
27
27
 
28
28
  # Optional: Override default models
29
- # OpenAI models: gpt-4.1-mini, gpt-4.1-nano, gpt-4.1
29
+ # OpenAI models: gpt-5-mini, gpt-5-nano, gpt-5
30
30
  # Anthropic models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-5-20251101
31
31
  # Google models: gemini-2.5-flash-lite, gemini-2.5-pro, gemini-2.5-flash
32
32
  # LLM_CHAT_MODEL=gpt-4.1-nano
@@ -37,8 +37,8 @@ GOOGLE_API_KEY=your_google_api_key_here
37
37
  # Note: Anthropic doesn't provide embeddings, will fallback to OpenAI
38
38
  # LLM_EMBEDDING_MODEL=text-embedding-3-small
39
39
 
40
- # Optional: Set temperature (0-1, default: 0)
41
- # LLM_TEMPERATURE=0
40
+ # Optional: Set temperature (0-1, default: 1)
41
+ # LLM_TEMPERATURE=1
42
42
  \`\`\`
43
43
 
44
44
  ## Provider Selection
@@ -10,10 +10,10 @@ export const packageJsonTemplate = JSON.stringify({
10
10
  },
11
11
  dependencies: {
12
12
  "@langchain/anthropic": "^1.3.17",
13
- "@langchain/google-genai": "^2.1.17",
13
+ "@langchain/google-genai": "^2.1.18",
14
14
  "@langchain/openai": "^1.2.7",
15
15
  "@modelcontextprotocol/sdk": "^1.26.0",
16
- langchain: "^1.2.21",
16
+ langchain: "^1.2.23",
17
17
  next: "16.1.6",
18
18
  react: "19.2.4",
19
19
  "react-dom": "19.2.4",
@@ -1 +1 @@
1
- export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-4.1-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"text-embedding-004\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n console.warn(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"0\"),\n };\n}\nexport function getLLMDisplayName(config?: LLMConfig): string {\n const c = config || getLLMConfig();\n const providerNames: Record<LLMProvider, string> = {\n openai: \"OpenAI\",\n anthropic: \"Anthropic\",\n google: \"Google\",\n };\n return `${providerNames[c.provider]} (${c.chatModel})`;\n}\nexport function isLLMConfigured(): boolean {\n try {\n getLLMConfig();\n return true;\n } catch {\n return false;\n }\n}\n";
1
+ export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-5-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"text-embedding-004\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n console.warn(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"1\"),\n };\n}\nexport function getLLMDisplayName(config?: LLMConfig): string {\n const c = config || getLLMConfig();\n const providerNames: Record<LLMProvider, string> = {\n openai: \"OpenAI\",\n anthropic: \"Anthropic\",\n google: \"Google\",\n };\n return `${providerNames[c.provider]} (${c.chatModel})`;\n}\nexport function isLLMConfigured(): boolean {\n try {\n getLLMConfig();\n return true;\n } catch {\n return false;\n }\n}\n";
@@ -5,7 +5,7 @@ export const llmConfigTemplate = `import type {
5
5
  } from "@/services/llm/types";
6
6
  const PROVIDER_DEFAULTS: ProviderDefaults = {
7
7
  openai: {
8
- chat: "gpt-4.1-nano",
8
+ chat: "gpt-5-nano",
9
9
  embedding: "text-embedding-3-small",
10
10
  },
11
11
  anthropic: {
@@ -49,7 +49,7 @@ export function getLLMConfig(): LLMConfig {
49
49
  provider,
50
50
  chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,
51
51
  embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,
52
- temperature: parseFloat(process.env.LLM_TEMPERATURE || "0"),
52
+ temperature: parseFloat(process.env.LLM_TEMPERATURE || "1"),
53
53
  };
54
54
  }
55
55
  export function getLLMDisplayName(config?: LLMConfig): string {
@@ -1 +1 @@
1
- export declare const mcpServerTemplate = "import { 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, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: (DocsChunk & { embedding: number[] })[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/**\n * Cosine similarity between two vectors\n */\nfunction cosineSim(a: number[], b: number[]): number {\n let dot = 0,\n na = 0,\n nb = 0;\n for (let i = 0; i < a.length; i++) {\n const x = a[i];\n const y = b[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\n/**\n * Build or rebuild the documentation index\n */\nexport async 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 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 const vectors = await embeddings.embedDocuments(chunks.map((c) => c.text));\n\n docsIndex.chunks = chunks.map((c, i) => ({\n ...c,\n embedding: vectors[i],\n }));\n docsIndex.ready = true;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n docsIndex.ready = false;\n docsIndex.chunks = [];\n }\n if (!docsIndex.ready) {\n await buildDocsIndex();\n }\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 const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n const queryVector = await embeddings.embedQuery(query);\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: cosineSim(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 { 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, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: (DocsChunk & { embedding: number[] })[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/**\n * Cosine similarity between two vectors\n */\nfunction cosineSim(a: number[], b: number[]): number {\n let dot = 0,\n na = 0,\n nb = 0;\n for (let i = 0; i < a.length; i++) {\n const x = a[i];\n const y = b[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\n/**\n * Build or rebuild the documentation index\n */\nexport async 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 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 const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\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 vectors.push(...batchVectors);\n }\n\n docsIndex.chunks = chunks.map((c, i) => ({\n ...c,\n embedding: vectors[i],\n }));\n docsIndex.ready = true;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n docsIndex.ready = false;\n docsIndex.chunks = [];\n }\n if (!docsIndex.ready) {\n await buildDocsIndex();\n }\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 const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n const queryVector = await embeddings.embedQuery(query);\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: cosineSim(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";
@@ -59,7 +59,17 @@ export async function buildDocsIndex(force = false): Promise<void> {
59
59
 
60
60
  const config = getLLMConfig();
61
61
  const embeddings = createEmbeddings(config);
62
- const vectors = await embeddings.embedDocuments(chunks.map((c) => c.text));
62
+
63
+ // Process embeddings in small batches to avoid exceeding token limits
64
+ const BATCH_SIZE = 10;
65
+ const texts = chunks.map((c) => c.text);
66
+ const vectors: number[][] = [];
67
+
68
+ for (let i = 0; i < texts.length; i += BATCH_SIZE) {
69
+ const batch = texts.slice(i, i + BATCH_SIZE);
70
+ const batchVectors = await embeddings.embedDocuments(batch);
71
+ vectors.push(...batchVectors);
72
+ }
63
73
 
64
74
  docsIndex.chunks = chunks.map((c, i) => ({
65
75
  ...c,
@@ -1 +1 @@
1
- export declare const mcpToolsTemplate = "import path from \"node:path\";\nimport fs from \"node:fs/promises\";\nimport type {\n MCPToolDefinition,\n DocsResource,\n DocsChunk,\n GetDocParams,\n ListDocsParams,\n} from \"@/services/mcp/types\";\n\nconst PROJECT_ROOT = process.cwd();\nconst APP_DIR = path.join(PROJECT_ROOT, \"app\");\nconst VALID_EXT = new Set([\".ts\", \".tsx\", \".js\", \".jsx\"]);\n\n/**\n * Tool definitions for MCP - these describe the available tools\n */\nexport const DOCS_TOOLS: MCPToolDefinition[] = [\n {\n name: \"search_docs\",\n description:\n \"Search through the documentation content using semantic search. Returns relevant chunks of documentation based on the query.\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: {\n type: \"string\",\n description: \"The search query to find relevant documentation\",\n },\n limit: {\n type: \"number\",\n description: \"Maximum number of results to return (default: 6)\",\n },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"get_doc\",\n description:\n \"Get the full content of a specific documentation page by its path.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: {\n type: \"string\",\n description:\n \"The file path to the documentation page (e.g., 'app/getting-started/page.tsx')\",\n },\n },\n required: [\"path\"],\n },\n },\n {\n name: \"list_docs\",\n description:\n \"List all available documentation pages, optionally filtered by directory.\",\n inputSchema: {\n type: \"object\",\n properties: {\n directory: {\n type: \"string\",\n description:\n \"Optional directory to filter results (e.g., 'components')\",\n },\n },\n },\n },\n];\n\n/**\n * Recursively walk directory to find documentation files\n */\nasync function* walkDocs(dir: string): AsyncGenerator<string> {\n const entries = await fs.readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if ([\"node_modules\", \".next\", \".git\", \"api\"].includes(entry.name)) {\n continue;\n }\n yield* walkDocs(fullPath);\n } else {\n const ext = path.extname(entry.name).toLowerCase();\n if (VALID_EXT.has(ext) && entry.name.startsWith(\"page.\")) {\n yield fullPath;\n }\n }\n }\n}\n\n/**\n * Extract content blocks from a file\n */\nfunction extractContentBlocks(fileText: string): string[] {\n const results: string[] = [];\n\n const tplRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*`((?:\\\\`|[^`])*)`\\s*;/g;\n let m: RegExpExecArray | null;\n while ((m = tplRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const sglRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*'([^']*)'\\s*;/g;\n while ((m = sglRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const dblRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*\"([^\"]*)\"\\s*;/g;\n while ((m = dblRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n return results;\n}\n\n/**\n * Get the title from markdown content\n */\nfunction extractTitle(content: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match ? match[1].trim() : \"Untitled\";\n}\n\n/**\n * List all documentation resources\n */\nexport async function listDocs(\n params?: ListDocsParams,\n): Promise<DocsResource[]> {\n const resources: DocsResource[] = [];\n const filterDir = params?.directory;\n\n for await (const filePath of walkDocs(APP_DIR)) {\n const relativePath = path.relative(PROJECT_ROOT, filePath);\n\n if (filterDir && !relativePath.includes(filterDir)) {\n continue;\n }\n\n try {\n const fileContent = await fs.readFile(filePath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(relativePath).replace(/^app\\/?/, \"\") || \"/\";\n\n resources.push({\n uri: `docs://${docPath}`,\n name: title,\n path: relativePath,\n content,\n });\n } catch {\n // Skip files that can't be read\n }\n }\n\n return resources;\n}\n\n/**\n * Get a specific documentation page\n */\nexport async function getDoc(\n params: GetDocParams,\n): Promise<DocsResource | null> {\n let targetPath = params.path;\n\n // Normalize path\n if (!targetPath.startsWith(\"app/\")) {\n targetPath = `app/${targetPath}`;\n }\n if (!targetPath.includes(\"page.\")) {\n targetPath = path.join(targetPath, \"page.tsx\");\n }\n\n const fullPath = path.join(PROJECT_ROOT, targetPath);\n\n try {\n const fileContent = await fs.readFile(fullPath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(targetPath).replace(/^app\\/?/, \"\") || \"/\";\n\n return {\n uri: `docs://${docPath}`,\n name: title,\n path: targetPath,\n content,\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Chunk text for embeddings\n */\nexport function chunkText(\n text: string,\n chunkSize = 1500,\n overlap = 200,\n): string[] {\n const chunks: string[] = [];\n let i = 0;\n while (i < text.length) {\n const end = Math.min(i + chunkSize, text.length);\n chunks.push(text.slice(i, end));\n if (end === text.length) break;\n i = end - overlap;\n if (i < 0) i = 0;\n }\n return chunks;\n}\n\n/**\n * Get all documentation chunks for indexing\n */\nexport async function getAllDocsChunks(): Promise<DocsChunk[]> {\n const allChunks: DocsChunk[] = [];\n const docs = await listDocs();\n\n for (const doc of docs) {\n const cleanContent = doc.content\n .replace(/\\r\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .slice(0, 200_000);\n\n const textChunks = chunkText(cleanContent);\n for (let i = 0; i < textChunks.length; i++) {\n allChunks.push({\n id: `${doc.path}:${i}`,\n text: textChunks[i],\n path: doc.path,\n uri: doc.uri,\n });\n }\n }\n\n return allChunks;\n}\n";
1
+ export declare const mcpToolsTemplate = "import path from \"node:path\";\nimport fs from \"node:fs/promises\";\nimport type {\n MCPToolDefinition,\n DocsResource,\n DocsChunk,\n GetDocParams,\n ListDocsParams,\n} from \"@/services/mcp/types\";\n\nconst PROJECT_ROOT = process.cwd();\nconst APP_DIR = path.join(PROJECT_ROOT, \"app\");\nconst VALID_EXT = new Set([\".ts\", \".tsx\", \".js\", \".jsx\"]);\n\n/**\n * Tool definitions for MCP - these describe the available tools\n */\nexport const DOCS_TOOLS: MCPToolDefinition[] = [\n {\n name: \"search_docs\",\n description:\n \"Search through the documentation content using semantic search. Returns relevant chunks of documentation based on the query.\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: {\n type: \"string\",\n description: \"The search query to find relevant documentation\",\n },\n limit: {\n type: \"number\",\n description: \"Maximum number of results to return (default: 6)\",\n },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"get_doc\",\n description:\n \"Get the full content of a specific documentation page by its path.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: {\n type: \"string\",\n description:\n \"The file path to the documentation page (e.g., 'app/getting-started/page.tsx')\",\n },\n },\n required: [\"path\"],\n },\n },\n {\n name: \"list_docs\",\n description:\n \"List all available documentation pages, optionally filtered by directory.\",\n inputSchema: {\n type: \"object\",\n properties: {\n directory: {\n type: \"string\",\n description:\n \"Optional directory to filter results (e.g., 'components')\",\n },\n },\n },\n },\n];\n\n/**\n * Recursively walk directory to find documentation files\n */\nasync function* walkDocs(dir: string): AsyncGenerator<string> {\n const entries = await fs.readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if ([\"node_modules\", \".next\", \".git\", \"api\"].includes(entry.name)) {\n continue;\n }\n yield* walkDocs(fullPath);\n } else {\n const ext = path.extname(entry.name).toLowerCase();\n if (VALID_EXT.has(ext) && entry.name.startsWith(\"page.\")) {\n yield fullPath;\n }\n }\n }\n}\n\n/**\n * Extract content blocks from a file\n */\nfunction extractContentBlocks(fileText: string): string[] {\n const results: string[] = [];\n\n const tplRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*`((?:\\\\`|[^`])*)`\\s*;/g;\n let m: RegExpExecArray | null;\n while ((m = tplRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const sglRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*'([^']*)'\\s*;/g;\n while ((m = sglRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const dblRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*\"([^\"]*)\"\\s*;/g;\n while ((m = dblRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n return results;\n}\n\n/**\n * Get the title from markdown content\n */\nfunction extractTitle(content: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match ? match[1].trim() : \"Untitled\";\n}\n\n/**\n * List all documentation resources\n */\nexport async function listDocs(\n params?: ListDocsParams,\n): Promise<DocsResource[]> {\n const resources: DocsResource[] = [];\n const filterDir = params?.directory;\n\n for await (const filePath of walkDocs(APP_DIR)) {\n const relativePath = path.relative(PROJECT_ROOT, filePath);\n\n if (filterDir && !relativePath.includes(filterDir)) {\n continue;\n }\n\n try {\n const fileContent = await fs.readFile(filePath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(relativePath).replace(/^app\\/?/, \"\") || \"/\";\n\n resources.push({\n uri: `docs://${docPath}`,\n name: title,\n path: relativePath,\n content,\n });\n } catch {\n // Skip files that can't be read\n }\n }\n\n return resources;\n}\n\n/**\n * Get a specific documentation page\n */\nexport async function getDoc(\n params: GetDocParams,\n): Promise<DocsResource | null> {\n let targetPath = params.path;\n\n // Normalize path\n if (!targetPath.startsWith(\"app/\")) {\n targetPath = `app/${targetPath}`;\n }\n if (!targetPath.includes(\"page.\")) {\n targetPath = path.join(targetPath, \"page.tsx\");\n }\n\n const fullPath = path.join(PROJECT_ROOT, targetPath);\n\n try {\n const fileContent = await fs.readFile(fullPath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(targetPath).replace(/^app\\/?/, \"\") || \"/\";\n\n return {\n uri: `docs://${docPath}`,\n name: title,\n path: targetPath,\n content,\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Chunk text for embeddings\n */\nexport function chunkText(\n text: string,\n chunkSize = 800,\n overlap = 100,\n): string[] {\n const chunks: string[] = [];\n let i = 0;\n while (i < text.length) {\n const end = Math.min(i + chunkSize, text.length);\n chunks.push(text.slice(i, end));\n if (end === text.length) break;\n i = end - overlap;\n if (i < 0) i = 0;\n }\n return chunks;\n}\n\n/**\n * Get all documentation chunks for indexing\n */\nexport async function getAllDocsChunks(): Promise<DocsChunk[]> {\n const allChunks: DocsChunk[] = [];\n const docs = await listDocs();\n\n for (const doc of docs) {\n const cleanContent = doc.content\n .replace(/\\r\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .slice(0, 200_000);\n\n const textChunks = chunkText(cleanContent);\n for (let i = 0; i < textChunks.length; i++) {\n allChunks.push({\n id: `${doc.path}:${i}`,\n text: textChunks[i],\n path: doc.path,\n uri: doc.uri,\n });\n }\n }\n\n return allChunks;\n}\n";
@@ -200,8 +200,8 @@ export async function getDoc(
200
200
  */
201
201
  export function chunkText(
202
202
  text: string,
203
- chunkSize = 1500,
204
- overlap = 200,
203
+ chunkSize = 800,
204
+ overlap = 100,
205
205
  ): string[] {
206
206
  const chunks: string[] = [];
207
207
  let i = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doccupine",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
4
4
  "description": "Document management system that allows you to store, organize, and share your documentation with ease. AI-ready.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {