doccupine 0.0.53 → 0.0.54

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 clickOutsideTemplate = "import { RefObject, useEffect } from \"react\";\n\nexport function useOnClickOutside(\n refs: RefObject<HTMLElement | null>[],\n cb: () => void,\n) {\n useEffect(() => {\n function handleClickOutside(event: MouseEvent) {\n if (\n refs &&\n refs\n .map(\n (ref) =>\n ref && ref.current && ref.current.contains(event.target as Node),\n )\n .every((i) => i === false)\n ) {\n cb();\n }\n }\n document.addEventListener(\"mousedown\", handleClickOutside);\n return () => {\n document.removeEventListener(\"mousedown\", handleClickOutside);\n };\n }, [refs, cb]);\n}\n";
1
+ export declare const clickOutsideTemplate = "import { RefObject, useCallback, useEffect } from \"react\";\n\nexport function useOnClickOutside(\n refs: RefObject<HTMLElement | null>[],\n cb: () => void,\n) {\n // Stable callback ref to avoid re-subscribing on every render\n const handleClickOutside = useCallback(\n (event: MouseEvent) => {\n if (\n refs.every(\n (ref) => !ref.current || !ref.current.contains(event.target as Node),\n )\n ) {\n cb();\n }\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [...refs, cb],\n );\n\n useEffect(() => {\n document.addEventListener(\"mousedown\", handleClickOutside);\n return () => {\n document.removeEventListener(\"mousedown\", handleClickOutside);\n };\n }, [handleClickOutside]);\n}\n";
@@ -1,27 +1,29 @@
1
- export const clickOutsideTemplate = `import { RefObject, useEffect } from "react";
1
+ export const clickOutsideTemplate = `import { RefObject, useCallback, useEffect } from "react";
2
2
 
3
3
  export function useOnClickOutside(
4
4
  refs: RefObject<HTMLElement | null>[],
5
5
  cb: () => void,
6
6
  ) {
7
- useEffect(() => {
8
- function handleClickOutside(event: MouseEvent) {
7
+ // Stable callback ref to avoid re-subscribing on every render
8
+ const handleClickOutside = useCallback(
9
+ (event: MouseEvent) => {
9
10
  if (
10
- refs &&
11
- refs
12
- .map(
13
- (ref) =>
14
- ref && ref.current && ref.current.contains(event.target as Node),
15
- )
16
- .every((i) => i === false)
11
+ refs.every(
12
+ (ref) => !ref.current || !ref.current.contains(event.target as Node),
13
+ )
17
14
  ) {
18
15
  cb();
19
16
  }
20
- }
17
+ },
18
+ // eslint-disable-next-line react-hooks/exhaustive-deps
19
+ [...refs, cb],
20
+ );
21
+
22
+ useEffect(() => {
21
23
  document.addEventListener("mousedown", handleClickOutside);
22
24
  return () => {
23
25
  document.removeEventListener("mousedown", handleClickOutside);
24
26
  };
25
- }, [refs, cb]);
27
+ }, [handleClickOutside]);
26
28
  }
27
29
  `;
@@ -1 +1 @@
1
- export declare const docsSideBarTemplate = "\"use client\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { Space } from \"cherry-styled-components\";\nimport {\n StyledIndexSidebar,\n StyledIndexSidebarLink,\n StyledIndexSidebarLabel,\n} from \"@/components/layout/DocsComponents\";\n\nexport interface Heading {\n id: string;\n text: string;\n level: number;\n}\n\nexport function DocsSideBar({ headings }: { headings: Heading[] }) {\n const [activeId, setActiveId] = useState<string>(\"\");\n\n const getScrollOffset = useCallback(() => {\n return document.getElementById(\"static-links\") ? 90 : 18;\n }, []);\n\n const handleScroll = useCallback(() => {\n if (headings.length === 0) return;\n\n const offset = getScrollOffset();\n\n const headingElements = headings\n .map((heading) => document.getElementById(heading.id))\n .filter(Boolean) as HTMLElement[];\n\n if (headingElements.length === 0) return;\n\n const windowHeight = window.innerHeight;\n\n const visibleHeadings = headingElements.filter((element) => {\n const rect = element.getBoundingClientRect();\n const elementTop = rect.top;\n const elementBottom = rect.bottom;\n return elementTop < windowHeight && elementBottom > -50;\n });\n\n if (visibleHeadings.length > 0) {\n let closestHeading = visibleHeadings[0];\n let closestDistance = Math.abs(\n closestHeading.getBoundingClientRect().top - offset,\n );\n for (const heading of visibleHeadings) {\n const distance = Math.abs(heading.getBoundingClientRect().top - offset);\n if (\n distance < closestDistance &&\n heading.getBoundingClientRect().top <= windowHeight * 0.3\n ) {\n closestDistance = distance;\n closestHeading = heading;\n }\n }\n setActiveId(closestHeading.id);\n return;\n }\n\n let currentActiveId = headings[0].id;\n for (const element of headingElements) {\n const rect = element.getBoundingClientRect();\n if (rect.top <= offset) {\n currentActiveId = element.id;\n } else {\n break;\n }\n }\n setActiveId(currentActiveId);\n }, [headings, getScrollOffset]);\n\n useEffect(() => {\n if (headings.length === 0) return;\n // Run initial scroll check on next frame to avoid synchronous setState in effect\n const rafId = requestAnimationFrame(handleScroll);\n let timeoutId: NodeJS.Timeout;\n const throttledHandleScroll = () => {\n clearTimeout(timeoutId);\n timeoutId = setTimeout(handleScroll, 50);\n };\n window.addEventListener(\"scroll\", throttledHandleScroll);\n window.addEventListener(\"resize\", handleScroll);\n return () => {\n window.removeEventListener(\"scroll\", throttledHandleScroll);\n window.removeEventListener(\"resize\", handleScroll);\n cancelAnimationFrame(rafId);\n clearTimeout(timeoutId);\n };\n }, [handleScroll, headings]);\n\n const handleHeadingClick = (headingId: string) => {\n const element = document.getElementById(headingId);\n if (element) {\n const offset = getScrollOffset();\n const elementPosition =\n element.getBoundingClientRect().top + window.scrollY;\n window.scrollTo({ top: elementPosition - offset, behavior: \"smooth\" });\n }\n };\n\n return (\n <StyledIndexSidebar>\n {headings?.length > 0 && (\n <>\n <StyledIndexSidebarLabel>On this page</StyledIndexSidebarLabel>\n <Space $size={20} />\n </>\n )}\n {headings.map((heading, index) => (\n <li\n key={index}\n style={{ paddingLeft: `${(heading.level - 1) * 16}px` }}\n >\n <StyledIndexSidebarLink\n href={`#${heading.id}`}\n onClick={(e) => {\n e.preventDefault();\n handleHeadingClick(heading.id);\n }}\n $isActive={activeId === heading.id}\n >\n {heading.text}\n </StyledIndexSidebarLink>\n </li>\n ))}\n </StyledIndexSidebar>\n );\n}\n";
1
+ export declare const docsSideBarTemplate = "\"use client\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { Space } from \"cherry-styled-components\";\nimport {\n StyledIndexSidebar,\n StyledIndexSidebarLink,\n StyledIndexSidebarLabel,\n} from \"@/components/layout/DocsComponents\";\n\nexport interface Heading {\n id: string;\n text: string;\n level: number;\n}\n\nexport function DocsSideBar({ headings }: { headings: Heading[] }) {\n const [activeId, setActiveId] = useState<string>(\"\");\n\n const getScrollOffset = useCallback(() => {\n return document.getElementById(\"static-links\") ? 90 : 18;\n }, []);\n\n const handleScroll = useCallback(() => {\n if (headings.length === 0) return;\n\n const offset = getScrollOffset();\n\n const headingElements = headings\n .map((heading) => document.getElementById(heading.id))\n .filter((el): el is HTMLElement => el !== null);\n\n if (headingElements.length === 0) return;\n\n const windowHeight = window.innerHeight;\n\n const visibleHeadings = headingElements.filter((element) => {\n const rect = element.getBoundingClientRect();\n const elementTop = rect.top;\n const elementBottom = rect.bottom;\n return elementTop < windowHeight && elementBottom > -50;\n });\n\n if (visibleHeadings.length > 0) {\n let closestHeading = visibleHeadings[0];\n let closestDistance = Math.abs(\n closestHeading.getBoundingClientRect().top - offset,\n );\n for (const heading of visibleHeadings) {\n const distance = Math.abs(heading.getBoundingClientRect().top - offset);\n if (\n distance < closestDistance &&\n heading.getBoundingClientRect().top <= windowHeight * 0.3\n ) {\n closestDistance = distance;\n closestHeading = heading;\n }\n }\n setActiveId(closestHeading.id);\n return;\n }\n\n let currentActiveId = headings[0].id;\n for (const element of headingElements) {\n const rect = element.getBoundingClientRect();\n if (rect.top <= offset) {\n currentActiveId = element.id;\n } else {\n break;\n }\n }\n setActiveId(currentActiveId);\n }, [headings, getScrollOffset]);\n\n useEffect(() => {\n if (headings.length === 0) return;\n // Run initial scroll check on next frame to avoid synchronous setState in effect\n const rafId = requestAnimationFrame(handleScroll);\n let timeoutId: NodeJS.Timeout;\n const throttledHandleScroll = () => {\n clearTimeout(timeoutId);\n timeoutId = setTimeout(handleScroll, 50);\n };\n window.addEventListener(\"scroll\", throttledHandleScroll);\n window.addEventListener(\"resize\", handleScroll);\n return () => {\n window.removeEventListener(\"scroll\", throttledHandleScroll);\n window.removeEventListener(\"resize\", handleScroll);\n cancelAnimationFrame(rafId);\n clearTimeout(timeoutId);\n };\n }, [handleScroll, headings]);\n\n const handleHeadingClick = (headingId: string) => {\n const element = document.getElementById(headingId);\n if (element) {\n const offset = getScrollOffset();\n const elementPosition =\n element.getBoundingClientRect().top + window.scrollY;\n window.scrollTo({ top: elementPosition - offset, behavior: \"smooth\" });\n }\n };\n\n return (\n <StyledIndexSidebar>\n {headings?.length > 0 && (\n <>\n <StyledIndexSidebarLabel>On this page</StyledIndexSidebarLabel>\n <Space $size={20} />\n </>\n )}\n {headings.map((heading, index) => (\n <li\n key={index}\n style={{ paddingLeft: `${(heading.level - 1) * 16}px` }}\n >\n <StyledIndexSidebarLink\n href={`#${heading.id}`}\n onClick={(e) => {\n e.preventDefault();\n handleHeadingClick(heading.id);\n }}\n $isActive={activeId === heading.id}\n >\n {heading.text}\n </StyledIndexSidebarLink>\n </li>\n ))}\n </StyledIndexSidebar>\n );\n}\n";
@@ -27,7 +27,7 @@ export function DocsSideBar({ headings }: { headings: Heading[] }) {
27
27
 
28
28
  const headingElements = headings
29
29
  .map((heading) => document.getElementById(heading.id))
30
- .filter(Boolean) as HTMLElement[];
30
+ .filter((el): el is HTMLElement => el !== null);
31
31
 
32
32
  if (headingElements.length === 0) return;
33
33
 
@@ -1 +1 @@
1
- export declare const accordionTemplate = "\"use client\";\nimport { useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { styledText, Theme } from \"cherry-styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\n\nconst StyledAccordion = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n padding: 20px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n width: 100%;\n`;\n\nconst StyledAccordionTitle = styled.h3<{ theme: Theme; $isOpen: boolean }>`\n cursor: pointer;\n margin: 0;\n padding: 0 40px 0 0;\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.primary};\n transition: color 0.3s ease;\n position: relative;\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n }\n\n & .lucide-chevron-down {\n position: absolute;\n top: 50%;\n transform: translateY(-50%);\n right: 0;\n transition: transform 0.3s ease;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n transform: translateY(-50%) rotate(180deg);\n `}\n }\n`;\n\nconst StyledAccordionContent = styled.div<{ theme: Theme; $isOpen: boolean }>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.grayDark};\n height: 0;\n overflow: clip;\n transition: all 0.3s ease;\n display: flex;\n flex-direction: column;\n gap: 20px;\n flex-wrap: wrap;\n flex: 1;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n margin: 20px 0 0;\n height: auto;\n `}\n`;\n\nexport interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {\n children: React.ReactNode;\n title: string;\n}\n\nfunction Accordion({ children, title }: AccordionProps) {\n const [isOpen, setIsOpen] = useState(false);\n\n return (\n <StyledAccordion>\n <StyledAccordionTitle onClick={() => setIsOpen(!isOpen)} $isOpen={isOpen}>\n {title} <Icon name=\"ChevronDown\" />\n </StyledAccordionTitle>\n <StyledAccordionContent $isOpen={isOpen}>\n {children}\n </StyledAccordionContent>\n </StyledAccordion>\n );\n}\n\nexport { Accordion };\n";
1
+ export declare const accordionTemplate = "\"use client\";\nimport { useState } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport { styledText, Theme } from \"cherry-styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\n\nconst StyledAccordion = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n padding: 20px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n width: 100%;\n`;\n\nconst StyledAccordionTitle = styled.h3<{ theme: Theme; $isOpen: boolean }>`\n cursor: pointer;\n margin: 0;\n padding: 0 40px 0 0;\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.primary};\n transition: color 0.3s ease;\n position: relative;\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n }\n\n & .lucide-chevron-down {\n position: absolute;\n top: 50%;\n transform: translateY(-50%);\n right: 0;\n transition: transform 0.3s ease;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n transform: translateY(-50%) rotate(180deg);\n `}\n }\n`;\n\nconst StyledAccordionContent = styled.div<{ theme: Theme; $isOpen: boolean }>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.grayDark};\n height: 0;\n overflow: clip;\n transition: all 0.3s ease;\n display: flex;\n flex-direction: column;\n gap: 20px;\n flex-wrap: wrap;\n flex: 1;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n margin: 20px 0 0;\n height: auto;\n `}\n`;\n\nexport interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {\n children: React.ReactNode;\n title: string;\n}\n\nfunction Accordion({ children, title }: AccordionProps) {\n const [isOpen, setIsOpen] = useState(false);\n\n return (\n <StyledAccordion>\n <StyledAccordionTitle\n onClick={() => setIsOpen(!isOpen)}\n $isOpen={isOpen}\n role=\"button\"\n aria-expanded={isOpen}\n >\n {title} <Icon name=\"ChevronDown\" />\n </StyledAccordionTitle>\n <StyledAccordionContent $isOpen={isOpen}>\n {children}\n </StyledAccordionContent>\n </StyledAccordion>\n );\n}\n\nexport { Accordion };\n";
@@ -72,7 +72,12 @@ function Accordion({ children, title }: AccordionProps) {
72
72
 
73
73
  return (
74
74
  <StyledAccordion>
75
- <StyledAccordionTitle onClick={() => setIsOpen(!isOpen)} $isOpen={isOpen}>
75
+ <StyledAccordionTitle
76
+ onClick={() => setIsOpen(!isOpen)}
77
+ $isOpen={isOpen}
78
+ role="button"
79
+ aria-expanded={isOpen}
80
+ >
76
81
  {title} <Icon name="ChevronDown" />
77
82
  </StyledAccordionTitle>
78
83
  <StyledAccordionContent $isOpen={isOpen}>
@@ -1 +1 @@
1
- export declare const themeToggleTemplate = "\"use client\";\nimport { Theme, resetButton } from \"cherry-styled-components\";\nimport styled, { css, useTheme } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { theme as themeLight, themeDark } from \"@/app/theme\";\nimport { useThemeOverride } from \"@/components/layout/ClientThemeProvider\";\n\nconst StyledThemeToggle = styled.button<{ theme: Theme; $hidden?: boolean }>`\n ${resetButton}\n width: 56px;\n height: 30px;\n border-radius: 30px;\n display: flex;\n position: relative;\n margin: auto 0;\n transform: scale(1);\n background: ${({ theme }) => theme.colors.light};\n\n &::after {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n transition: all 0.3s ease;\n z-index: 1;\n ${({ theme }) =>\n theme.isDark &&\n css`\n transform: translateX(27px);\n `}\n }\n\n ${({ $hidden }) =>\n $hidden &&\n css`\n display: none;\n `}\n\n & svg {\n width: 16px;\n height: 16px;\n object-fit: contain;\n margin: auto;\n transition: all 0.3s ease;\n position: relative;\n z-index: 2;\n }\n\n & .lucide-sun {\n transform: translateX(1px);\n }\n\n & svg[stroke] {\n stroke: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n transform: scale(1.05);\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n\n & svg[stroke] {\n stroke: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n }\n\n &:active {\n transform: scale(0.97);\n }\n`;\n\nfunction ToggleTheme({ $hidden }: { $hidden?: boolean }) {\n const { setTheme } = useThemeOverride();\n const theme = useTheme() as Theme;\n\n return (\n <StyledThemeToggle\n onClick={async () => {\n const nextTheme = theme.isDark ? \"light\" : \"dark\";\n setTheme(nextTheme === \"light\" ? themeLight : themeDark);\n await fetch(\"/api/theme\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ theme: nextTheme }),\n });\n }}\n $hidden={$hidden}\n aria-label=\"Toggle Theme\"\n >\n <Icon name=\"Sun\" className=\"light\" />\n <Icon name=\"MoonStar\" className=\"dark\" />\n </StyledThemeToggle>\n );\n}\n\nfunction ToggleThemeLoading() {\n return (\n <StyledThemeToggle $hidden aria-label=\"Toggle Theme\">\n <Icon name=\"MoonStar\" className=\"dark\" />\n <Icon name=\"Sun\" className=\"light\" />\n </StyledThemeToggle>\n );\n}\n\nexport { ToggleTheme, ToggleThemeLoading };\n";
1
+ export declare const themeToggleTemplate = "\"use client\";\nimport { Theme, resetButton } from \"cherry-styled-components\";\nimport styled, { css, useTheme } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { theme as themeLight, themeDark } from \"@/app/theme\";\nimport { useThemeOverride } from \"@/components/layout/ClientThemeProvider\";\n\nconst StyledThemeToggle = styled.button<{ theme: Theme; $hidden?: boolean }>`\n ${resetButton}\n width: 56px;\n height: 30px;\n border-radius: 30px;\n display: flex;\n position: relative;\n margin: auto 0;\n transform: scale(1);\n background: ${({ theme }) => theme.colors.light};\n\n &::after {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 24px;\n height: 24px;\n border-radius: 50%;\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n transition: all 0.3s ease;\n z-index: 1;\n ${({ theme }) =>\n theme.isDark &&\n css`\n transform: translateX(27px);\n `}\n }\n\n ${({ $hidden }) =>\n $hidden &&\n css`\n display: none;\n `}\n\n & svg {\n width: 16px;\n height: 16px;\n object-fit: contain;\n margin: auto;\n transition: all 0.3s ease;\n position: relative;\n z-index: 2;\n }\n\n & .lucide-sun {\n transform: translateX(1px);\n }\n\n & svg[stroke] {\n stroke: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n transform: scale(1.05);\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n\n & svg[stroke] {\n stroke: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n }\n\n &:active {\n transform: scale(0.97);\n }\n`;\n\nfunction ToggleTheme({ $hidden }: { $hidden?: boolean }) {\n const { setTheme } = useThemeOverride();\n const theme = useTheme() as Theme;\n\n return (\n <StyledThemeToggle\n onClick={async () => {\n const nextTheme = theme.isDark ? \"light\" : \"dark\";\n setTheme(nextTheme === \"light\" ? themeLight : themeDark);\n await fetch(\"/api/theme\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ theme: nextTheme }),\n }).catch((err) => console.error(\"Failed to persist theme:\", err));\n }}\n $hidden={$hidden}\n aria-label=\"Toggle Theme\"\n >\n <Icon name=\"Sun\" className=\"light\" />\n <Icon name=\"MoonStar\" className=\"dark\" />\n </StyledThemeToggle>\n );\n}\n\nfunction ToggleThemeLoading() {\n return (\n <StyledThemeToggle $hidden aria-label=\"Toggle Theme\">\n <Icon name=\"MoonStar\" className=\"dark\" />\n <Icon name=\"Sun\" className=\"light\" />\n </StyledThemeToggle>\n );\n}\n\nexport { ToggleTheme, ToggleThemeLoading };\n";
@@ -88,7 +88,7 @@ function ToggleTheme({ $hidden }: { $hidden?: boolean }) {
88
88
  method: "POST",
89
89
  headers: { "Content-Type": "application/json" },
90
90
  body: JSON.stringify({ theme: nextTheme }),
91
- });
91
+ }).catch((err) => console.error("Failed to persist theme:", err));
92
92
  }}
93
93
  $hidden={$hidden}
94
94
  aria-label="Toggle Theme"
@@ -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 * Built once at server startup since docs are static.\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/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\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 } 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 docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex();\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 (docs are static)\nindexReady = buildDocsIndex();\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 const queryVector = await getEmbeddings().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 * Built once at server startup since docs are static.\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/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\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 } 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 (docs are static)\nindexReady = buildDocsIndex();\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 const queryVector = await getEmbeddings().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";
@@ -95,9 +95,13 @@ export async function buildDocsIndex(force = false): Promise<void> {
95
95
  */
96
96
  export async function ensureDocsIndex(force = false): Promise<void> {
97
97
  if (force) {
98
+ // Wait for any in-flight build before starting a forced rebuild
99
+ if (docsIndex.building && indexReady) {
100
+ await indexReady.catch(() => {});
101
+ }
98
102
  docsIndex.ready = false;
99
103
  docsIndex.chunks = [];
100
- indexReady = buildDocsIndex();
104
+ indexReady = buildDocsIndex(true);
101
105
  return indexReady;
102
106
  }
103
107
  if (!indexReady) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doccupine",
3
- "version": "0.0.53",
3
+ "version": "0.0.54",
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": {