skrypt-ai 0.4.2 → 0.6.0

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.
Files changed (159) hide show
  1. package/dist/auth/index.d.ts +13 -3
  2. package/dist/auth/index.js +101 -9
  3. package/dist/auth/keychain.d.ts +5 -0
  4. package/dist/auth/keychain.js +82 -0
  5. package/dist/auth/notices.d.ts +3 -0
  6. package/dist/auth/notices.js +42 -0
  7. package/dist/autofix/index.d.ts +0 -4
  8. package/dist/autofix/index.js +10 -24
  9. package/dist/capture/browser.d.ts +11 -0
  10. package/dist/capture/browser.js +173 -0
  11. package/dist/capture/diff.d.ts +23 -0
  12. package/dist/capture/diff.js +52 -0
  13. package/dist/capture/index.d.ts +23 -0
  14. package/dist/capture/index.js +210 -0
  15. package/dist/capture/naming.d.ts +17 -0
  16. package/dist/capture/naming.js +45 -0
  17. package/dist/capture/parser.d.ts +15 -0
  18. package/dist/capture/parser.js +80 -0
  19. package/dist/capture/types.d.ts +57 -0
  20. package/dist/capture/types.js +1 -0
  21. package/dist/cli.js +20 -3
  22. package/dist/commands/autofix.js +136 -120
  23. package/dist/commands/cron.js +58 -47
  24. package/dist/commands/deploy.js +123 -102
  25. package/dist/commands/generate.js +125 -7
  26. package/dist/commands/heal.d.ts +10 -0
  27. package/dist/commands/heal.js +201 -0
  28. package/dist/commands/i18n.js +146 -111
  29. package/dist/commands/import.d.ts +2 -0
  30. package/dist/commands/import.js +157 -0
  31. package/dist/commands/init.js +19 -7
  32. package/dist/commands/lint.js +50 -44
  33. package/dist/commands/llms-txt.js +59 -49
  34. package/dist/commands/login.js +63 -34
  35. package/dist/commands/mcp.js +6 -0
  36. package/dist/commands/monitor.js +13 -8
  37. package/dist/commands/qa.d.ts +2 -0
  38. package/dist/commands/qa.js +43 -0
  39. package/dist/commands/review-pr.js +108 -92
  40. package/dist/commands/sdk.js +128 -122
  41. package/dist/commands/security.d.ts +2 -0
  42. package/dist/commands/security.js +109 -0
  43. package/dist/commands/test.js +91 -92
  44. package/dist/commands/version.js +104 -75
  45. package/dist/commands/watch.js +130 -114
  46. package/dist/config/types.js +2 -2
  47. package/dist/context-hub/index.d.ts +23 -0
  48. package/dist/context-hub/index.js +179 -0
  49. package/dist/context-hub/mappings.d.ts +8 -0
  50. package/dist/context-hub/mappings.js +55 -0
  51. package/dist/context-hub/types.d.ts +33 -0
  52. package/dist/context-hub/types.js +1 -0
  53. package/dist/generator/generator.js +39 -6
  54. package/dist/generator/types.d.ts +7 -0
  55. package/dist/generator/writer.d.ts +3 -1
  56. package/dist/generator/writer.js +36 -7
  57. package/dist/importers/confluence.d.ts +5 -0
  58. package/dist/importers/confluence.js +137 -0
  59. package/dist/importers/detect.d.ts +20 -0
  60. package/dist/importers/detect.js +121 -0
  61. package/dist/importers/docusaurus.d.ts +5 -0
  62. package/dist/importers/docusaurus.js +279 -0
  63. package/dist/importers/gitbook.d.ts +5 -0
  64. package/dist/importers/gitbook.js +189 -0
  65. package/dist/importers/github.d.ts +8 -0
  66. package/dist/importers/github.js +99 -0
  67. package/dist/importers/index.d.ts +15 -0
  68. package/dist/importers/index.js +30 -0
  69. package/dist/importers/markdown.d.ts +6 -0
  70. package/dist/importers/markdown.js +105 -0
  71. package/dist/importers/mintlify.d.ts +5 -0
  72. package/dist/importers/mintlify.js +172 -0
  73. package/dist/importers/notion.d.ts +5 -0
  74. package/dist/importers/notion.js +174 -0
  75. package/dist/importers/readme.d.ts +5 -0
  76. package/dist/importers/readme.js +184 -0
  77. package/dist/importers/transform.d.ts +90 -0
  78. package/dist/importers/transform.js +457 -0
  79. package/dist/importers/types.d.ts +37 -0
  80. package/dist/importers/types.js +1 -0
  81. package/dist/llm/anthropic-client.d.ts +1 -0
  82. package/dist/llm/anthropic-client.js +3 -1
  83. package/dist/llm/index.d.ts +6 -4
  84. package/dist/llm/index.js +76 -261
  85. package/dist/llm/openai-client.d.ts +1 -0
  86. package/dist/llm/openai-client.js +7 -2
  87. package/dist/plugins/index.js +7 -0
  88. package/dist/qa/checks.d.ts +10 -0
  89. package/dist/qa/checks.js +492 -0
  90. package/dist/qa/fixes.d.ts +30 -0
  91. package/dist/qa/fixes.js +277 -0
  92. package/dist/qa/index.d.ts +29 -0
  93. package/dist/qa/index.js +187 -0
  94. package/dist/qa/types.d.ts +24 -0
  95. package/dist/qa/types.js +1 -0
  96. package/dist/scanner/csharp.d.ts +23 -0
  97. package/dist/scanner/csharp.js +421 -0
  98. package/dist/scanner/index.js +53 -26
  99. package/dist/scanner/java.d.ts +39 -0
  100. package/dist/scanner/java.js +318 -0
  101. package/dist/scanner/kotlin.d.ts +23 -0
  102. package/dist/scanner/kotlin.js +389 -0
  103. package/dist/scanner/php.d.ts +57 -0
  104. package/dist/scanner/php.js +351 -0
  105. package/dist/scanner/python.js +17 -0
  106. package/dist/scanner/ruby.d.ts +36 -0
  107. package/dist/scanner/ruby.js +431 -0
  108. package/dist/scanner/swift.d.ts +25 -0
  109. package/dist/scanner/swift.js +392 -0
  110. package/dist/scanner/types.d.ts +1 -1
  111. package/dist/template/content/docs/_navigation.json +46 -0
  112. package/dist/template/content/docs/_sidebars.json +684 -0
  113. package/dist/template/content/docs/core.md +4544 -0
  114. package/dist/template/content/docs/index.mdx +89 -0
  115. package/dist/template/content/docs/integrations.md +1158 -0
  116. package/dist/template/content/docs/llms-full.md +403 -0
  117. package/dist/template/content/docs/llms.txt +4588 -0
  118. package/dist/template/content/docs/other.md +10379 -0
  119. package/dist/template/content/docs/tools.md +746 -0
  120. package/dist/template/content/docs/types.md +531 -0
  121. package/dist/template/docs.json +13 -11
  122. package/dist/template/mdx-components.tsx +27 -2
  123. package/dist/template/package.json +6 -0
  124. package/dist/template/public/search-index.json +1 -1
  125. package/dist/template/scripts/build-search-index.mjs +149 -13
  126. package/dist/template/src/app/api/chat/route.ts +83 -128
  127. package/dist/template/src/app/docs/[...slug]/page.tsx +75 -20
  128. package/dist/template/src/app/docs/llms-full.md +151 -4
  129. package/dist/template/src/app/docs/llms.txt +2464 -847
  130. package/dist/template/src/app/docs/page.mdx +48 -38
  131. package/dist/template/src/app/layout.tsx +3 -1
  132. package/dist/template/src/app/page.tsx +22 -8
  133. package/dist/template/src/components/ai-chat.tsx +73 -64
  134. package/dist/template/src/components/breadcrumbs.tsx +21 -23
  135. package/dist/template/src/components/copy-button.tsx +13 -9
  136. package/dist/template/src/components/copy-page-button.tsx +54 -0
  137. package/dist/template/src/components/docs-layout.tsx +37 -25
  138. package/dist/template/src/components/header.tsx +51 -10
  139. package/dist/template/src/components/mdx/card.tsx +17 -3
  140. package/dist/template/src/components/mdx/code-block.tsx +13 -9
  141. package/dist/template/src/components/mdx/code-group.tsx +13 -8
  142. package/dist/template/src/components/mdx/heading.tsx +15 -2
  143. package/dist/template/src/components/mdx/highlighted-code.tsx +13 -8
  144. package/dist/template/src/components/mdx/index.tsx +2 -0
  145. package/dist/template/src/components/mdx/mermaid.tsx +110 -0
  146. package/dist/template/src/components/mdx/screenshot.tsx +150 -0
  147. package/dist/template/src/components/scroll-to-hash.tsx +48 -0
  148. package/dist/template/src/components/sidebar.tsx +12 -18
  149. package/dist/template/src/components/table-of-contents.tsx +9 -0
  150. package/dist/template/src/lib/highlight.ts +3 -88
  151. package/dist/template/src/lib/navigation.ts +159 -0
  152. package/dist/template/src/lib/search-types.ts +4 -1
  153. package/dist/template/src/lib/search.ts +30 -7
  154. package/dist/template/src/styles/globals.css +17 -6
  155. package/dist/utils/files.d.ts +9 -1
  156. package/dist/utils/files.js +59 -10
  157. package/dist/utils/validation.d.ts +0 -3
  158. package/dist/utils/validation.js +0 -26
  159. package/package.json +5 -1
@@ -19,13 +19,15 @@ async function dirExists(dir) {
19
19
  async function getAllMDXFiles(dir) {
20
20
  const files = []
21
21
 
22
- async function walk(currentDir) {
22
+ async function walk(currentDir, depth = 0) {
23
+ if (depth > 20) return
23
24
  try {
24
25
  const entries = await readdir(currentDir, { withFileTypes: true })
25
26
  for (const entry of entries) {
26
27
  const fullPath = join(currentDir, entry.name)
28
+ if (entry.isSymbolicLink?.()) continue
27
29
  if (entry.isDirectory()) {
28
- await walk(fullPath)
30
+ await walk(fullPath, depth + 1)
29
31
  } else if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) {
30
32
  files.push(fullPath)
31
33
  }
@@ -39,14 +41,30 @@ async function getAllMDXFiles(dir) {
39
41
  return files
40
42
  }
41
43
 
44
+ /**
45
+ * Extract headings (h2, h3) as a separate searchable field.
46
+ * This lets users find pages by section names.
47
+ */
48
+ function extractHeadings(content) {
49
+ const headings = []
50
+ const regex = /^#{2,3}\s+(.+)$/gm
51
+ let match
52
+ while ((match = regex.exec(content)) !== null) {
53
+ // Strip any inline formatting
54
+ const clean = match[1].replace(/[*_`[\]]/g, '').trim()
55
+ if (clean) headings.push(clean)
56
+ }
57
+ return headings.join(' | ')
58
+ }
59
+
42
60
  function extractPlainText(content) {
43
61
  return content
44
62
  // Remove import statements
45
63
  .replace(/^import\s+.*$/gm, '')
46
64
  // Remove export statements
47
65
  .replace(/^export\s+.*$/gm, '')
48
- // Remove MDX/JSX components
49
- .replace(/<[^>]+>/g, '')
66
+ // Remove MDX/JSX components (but keep text content inside)
67
+ .replace(/<[^>]+>/g, ' ')
50
68
  // Remove code blocks
51
69
  .replace(/```[\s\S]*?```/g, '')
52
70
  .replace(/`[^`]+`/g, '')
@@ -68,19 +86,95 @@ function extractPlainText(content) {
68
86
  .trim()
69
87
  }
70
88
 
89
+ /**
90
+ * Slugify a heading for use as an anchor hash.
91
+ * Matches the logic in heading components (H2, H3).
92
+ */
93
+ function slugifyHeading(text) {
94
+ return text
95
+ .replace(/`/g, '')
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9]+/g, '-')
98
+ .replace(/(^-|-$)/g, '')
99
+ }
100
+
101
+ /**
102
+ * Split content into sections by h2 headings.
103
+ * Returns an array of { heading, slug, content } objects.
104
+ */
105
+ function splitBySections(content) {
106
+ const sections = []
107
+ const h2Regex = /^## (.+)$/gm
108
+ let lastIndex = 0
109
+ let lastHeading = null
110
+ let lastSlug = null
111
+ let match
112
+
113
+ while ((match = h2Regex.exec(content)) !== null) {
114
+ if (lastHeading !== null) {
115
+ sections.push({
116
+ heading: lastHeading,
117
+ slug: lastSlug,
118
+ content: content.slice(lastIndex, match.index),
119
+ })
120
+ }
121
+ lastHeading = match[1].replace(/[`*_[\]]/g, '').trim()
122
+ lastSlug = slugifyHeading(lastHeading)
123
+ lastIndex = match.index
124
+ }
125
+
126
+ // Last section
127
+ if (lastHeading !== null) {
128
+ sections.push({
129
+ heading: lastHeading,
130
+ slug: lastSlug,
131
+ content: content.slice(lastIndex),
132
+ })
133
+ }
134
+
135
+ return sections
136
+ }
137
+
138
+ /**
139
+ * Extract keywords from content — important terms that appear frequently
140
+ * or in headings, used to boost search relevance.
141
+ */
142
+ function extractKeywords(content, title) {
143
+ const words = new Map()
144
+
145
+ // Title words get high weight
146
+ for (const word of title.toLowerCase().split(/\s+/)) {
147
+ if (word.length > 2) words.set(word, (words.get(word) || 0) + 3)
148
+ }
149
+
150
+ // Heading words get medium weight
151
+ const headingRegex = /^#{1,3}\s+(.+)$/gm
152
+ let match
153
+ while ((match = headingRegex.exec(content)) !== null) {
154
+ for (const word of match[1].toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/)) {
155
+ if (word.length > 2) words.set(word, (words.get(word) || 0) + 2)
156
+ }
157
+ }
158
+
159
+ // Sort by weight and return top keywords
160
+ return [...words.entries()]
161
+ .sort((a, b) => b[1] - a[1])
162
+ .slice(0, 20)
163
+ .map(([word]) => word)
164
+ .join(' ')
165
+ }
166
+
71
167
  function getSlugFromContentPath(filePath) {
72
168
  const rel = relative(CONTENT_DIR, filePath)
73
169
  const slug = rel
74
170
  .replace(/\.(mdx?|md)$/, '')
75
- .replace(/\/index$/, '')
171
+ .replace(/(^|\/)index$/, '')
76
172
  .replace(/\\/g, '/')
77
173
 
78
174
  return slug ? `/docs/${slug}` : '/docs'
79
175
  }
80
176
 
81
177
  function getSlugFromAppPath(filePath) {
82
- // For App Router: src/app/docs/quickstart/page.mdx -> /docs/quickstart
83
- // src/app/docs/page.mdx -> /docs
84
178
  const rel = relative(APP_DOCS_DIR, filePath)
85
179
  const dir = dirname(rel)
86
180
  const name = basename(rel)
@@ -99,11 +193,9 @@ function getSlugFromAppPath(filePath) {
99
193
  }
100
194
 
101
195
  function extractTitle(content, filePath) {
102
- // Try to get title from first heading
103
196
  const h1Match = content.match(/^#\s+(.+)$/m)
104
197
  if (h1Match) return h1Match[1].trim()
105
198
 
106
- // Derive from file path
107
199
  const dir = dirname(filePath)
108
200
  const folderName = basename(dir)
109
201
  if (folderName && folderName !== 'docs' && folderName !== '.') {
@@ -122,10 +214,18 @@ async function buildSearchIndex() {
122
214
  schema: {
123
215
  id: 'string',
124
216
  title: 'string',
217
+ headings: 'string',
218
+ keywords: 'string',
125
219
  content: 'string',
126
220
  href: 'string',
127
221
  section: 'string',
128
222
  },
223
+ components: {
224
+ tokenizer: {
225
+ stemming: true,
226
+ language: 'english',
227
+ },
228
+ },
129
229
  })
130
230
 
131
231
  const documents = []
@@ -135,26 +235,57 @@ async function buildSearchIndex() {
135
235
  if (await dirExists(CONTENT_DIR)) {
136
236
  const contentFiles = await getAllMDXFiles(CONTENT_DIR)
137
237
  for (const file of contentFiles) {
238
+ // Skip metadata files
239
+ const fileName = basename(file).replace(/\.(mdx?|md)$/, '')
240
+ if (fileName.startsWith('_') || fileName.startsWith('llms') || fileName === 'README') continue
241
+
138
242
  try {
139
243
  const raw = await readFile(file, 'utf-8')
140
244
  const { data, content } = matter(raw)
141
245
 
142
246
  const title = data.title || extractTitle(content, file)
143
- const plainContent = extractPlainText(content)
144
247
  const href = getSlugFromContentPath(file)
145
248
 
146
249
  if (seen.has(href)) continue
147
250
  seen.add(href)
148
251
 
252
+ // Index the page itself
253
+ const plainContent = extractPlainText(content)
254
+ const headings = extractHeadings(content)
255
+ const keywords = extractKeywords(content, title)
256
+ const sectionLabel = data.section || data.category || title
257
+
149
258
  documents.push({
150
259
  id: href,
151
260
  title,
261
+ headings,
262
+ keywords,
152
263
  content: plainContent.slice(0, 5000),
153
264
  href,
154
- section: data.section || data.category || '',
265
+ section: sectionLabel,
155
266
  })
156
267
 
157
- console.log(` Indexed: ${href} (content/)`)
268
+ // Also index individual h2 sections as separate search documents
269
+ // This enables deep-linking search results (e.g. /docs/core#autofixbatch)
270
+ const sections = splitBySections(content)
271
+ for (const sec of sections) {
272
+ const sectionHref = `${href}#${sec.slug}`
273
+ if (seen.has(sectionHref)) continue
274
+ seen.add(sectionHref)
275
+
276
+ const sectionContent = extractPlainText(sec.content).slice(0, 2000)
277
+ documents.push({
278
+ id: sectionHref,
279
+ title: sec.heading,
280
+ headings: '',
281
+ keywords: sec.heading.toLowerCase(),
282
+ content: sectionContent,
283
+ href: sectionHref,
284
+ section: title, // parent page title as section label
285
+ })
286
+ }
287
+
288
+ console.log(` Indexed: ${href} (content/) + ${sections.length} sections`)
158
289
  } catch (err) {
159
290
  console.error(` Error indexing ${file}:`, err.message)
160
291
  }
@@ -165,8 +296,9 @@ async function buildSearchIndex() {
165
296
  if (await dirExists(APP_DOCS_DIR)) {
166
297
  const appFiles = await getAllMDXFiles(APP_DOCS_DIR)
167
298
  for (const file of appFiles) {
168
- // Skip catch-all route files and layout files
169
299
  if (file.includes('[...slug]') || file.includes('layout.')) continue
300
+ const appFileName = basename(file).replace(/\.(mdx?|md)$/, '')
301
+ if (appFileName.startsWith('_') || appFileName.startsWith('llms') || appFileName === 'README') continue
170
302
 
171
303
  try {
172
304
  const raw = await readFile(file, 'utf-8')
@@ -174,6 +306,8 @@ async function buildSearchIndex() {
174
306
 
175
307
  const title = data.title || extractTitle(content, file)
176
308
  const plainContent = extractPlainText(content)
309
+ const headings = extractHeadings(content)
310
+ const keywords = extractKeywords(content, title)
177
311
  const href = getSlugFromAppPath(file)
178
312
 
179
313
  if (seen.has(href)) continue
@@ -182,6 +316,8 @@ async function buildSearchIndex() {
182
316
  documents.push({
183
317
  id: href,
184
318
  title,
319
+ headings,
320
+ keywords,
185
321
  content: plainContent.slice(0, 5000),
186
322
  href,
187
323
  section: data.section || data.category || '',
@@ -1,10 +1,24 @@
1
- import { NextResponse } from 'next/server'
1
+ import { streamText, type CoreMessage } from 'ai'
2
+ import { createOpenAI } from '@ai-sdk/openai'
3
+ import { createAnthropic } from '@ai-sdk/anthropic'
2
4
  import { readFileSync, readdirSync, statSync, existsSync } from 'fs'
3
5
  import { join, extname } from 'path'
4
6
 
5
- interface Message {
6
- role: 'user' | 'assistant'
7
- content: string
7
+ // In-memory rate limiter
8
+ const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
9
+ const RATE_LIMIT = 20
10
+ const RATE_WINDOW = 60_000
11
+
12
+ function checkRateLimit(ip: string): boolean {
13
+ const now = Date.now()
14
+ const entry = rateLimitMap.get(ip)
15
+ if (!entry || now > entry.resetAt) {
16
+ rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_WINDOW })
17
+ return true
18
+ }
19
+ if (entry.count >= RATE_LIMIT) return false
20
+ entry.count++
21
+ return true
8
22
  }
9
23
 
10
24
  interface DocChunk {
@@ -19,13 +33,12 @@ let docChunks: DocChunk[] | null = null
19
33
  let lastIndexTime = 0
20
34
 
21
35
  function indexDocs(): DocChunk[] {
22
- // Return cached if recent (5 min)
23
36
  if (docChunks && Date.now() - lastIndexTime < 5 * 60 * 1000) {
24
37
  return docChunks
25
38
  }
26
39
 
27
40
  const chunks: DocChunk[] = []
28
- const docsDir = join(process.cwd(), 'src/app/docs')
41
+ const docsDir = join(process.cwd(), 'content/docs')
29
42
 
30
43
  if (!existsSync(docsDir)) {
31
44
  return chunks
@@ -45,7 +58,6 @@ function indexDocs(): DocChunk[] {
45
58
  const content = readFileSync(fullPath, 'utf-8')
46
59
  const docPath = `${basePath}/${entry.replace(/\.(mdx?|md)$/, '')}`.replace('/page', '')
47
60
 
48
- // Extract title from frontmatter or first heading
49
61
  let title = entry.replace(/\.(mdx?|md)$/, '')
50
62
  const titleMatch = content.match(/title:\s*["']?([^"'\n]+)["']?/m)
51
63
  if (titleMatch) {
@@ -55,16 +67,14 @@ function indexDocs(): DocChunk[] {
55
67
  if (h1Match) title = h1Match[1]
56
68
  }
57
69
 
58
- // Extract headings
59
70
  const headings = [...content.matchAll(/^#{1,3}\s+(.+)$/gm)].map(m => m[1])
60
71
 
61
- // Clean content (remove frontmatter, code blocks for search)
62
72
  let cleanContent = content
63
- .replace(/^---[\s\S]*?---/m, '') // Remove frontmatter
64
- .replace(/```[\s\S]*?```/g, '') // Remove code blocks
65
- .replace(/<[^>]+>/g, '') // Remove JSX/HTML
66
- .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Convert links to text
67
- .slice(0, 2000) // Limit size
73
+ .replace(/^---[\s\S]*?---/m, '')
74
+ .replace(/```[\s\S]*?```/g, '')
75
+ .replace(/<[^>]+>/g, '')
76
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
77
+ .slice(0, 2000)
68
78
 
69
79
  chunks.push({
70
80
  path: docPath || '/',
@@ -100,13 +110,11 @@ function searchDocs(query: string, chunks: DocChunk[]): DocChunk[] {
100
110
  const contentLower = chunk.content.toLowerCase()
101
111
  const titleLower = chunk.title.toLowerCase()
102
112
 
103
- // Title matches (high weight)
104
113
  if (titleLower.includes(queryLower)) score += 20
105
114
  queryWords.forEach(word => {
106
115
  if (titleLower.includes(word)) score += 5
107
116
  })
108
117
 
109
- // Heading matches (medium weight)
110
118
  chunk.headings.forEach(h => {
111
119
  const hLower = h.toLowerCase()
112
120
  if (hLower.includes(queryLower)) score += 10
@@ -115,10 +123,9 @@ function searchDocs(query: string, chunks: DocChunk[]): DocChunk[] {
115
123
  })
116
124
  })
117
125
 
118
- // Content matches (lower weight)
119
126
  queryWords.forEach(word => {
120
127
  const matches = (contentLower.match(new RegExp(escapeRegex(word), 'g')) || []).length
121
- score += Math.min(matches, 5) // Cap at 5 matches per word
128
+ score += Math.min(matches, 5)
122
129
  })
123
130
 
124
131
  return { chunk, score }
@@ -129,137 +136,85 @@ function searchDocs(query: string, chunks: DocChunk[]): DocChunk[] {
129
136
  .map(r => r.chunk)
130
137
  }
131
138
 
132
- async function generateAnswer(
133
- query: string,
134
- context: DocChunk[],
135
- history: Message[]
136
- ): Promise<{ content: string; citations: Array<{ title: string; path: string; snippet: string }> }> {
137
- // Check for API key
138
- const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY
139
-
140
- if (!apiKey) {
141
- // Fallback: return a simple search result without AI
142
- if (context.length === 0) {
143
- return {
144
- content: "I couldn't find any relevant documentation for your question. Try rephrasing or browsing the docs directly.",
145
- citations: [],
146
- }
147
- }
148
-
149
- return {
150
- content: `Based on the documentation, here's what I found:\n\n${context[0].content.slice(0, 500)}...\n\nSee the linked sources for more details.`,
151
- citations: context.slice(0, 3).map(c => ({
152
- title: c.title,
153
- path: `/docs${c.path}`,
154
- snippet: c.content.slice(0, 100),
155
- })),
156
- }
139
+ function getModel() {
140
+ if (process.env.OPENAI_API_KEY) {
141
+ const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY })
142
+ return openai('gpt-4o-mini')
157
143
  }
158
-
159
- // Build context for AI
160
- const contextText = context
161
- .map(c => `## ${c.title}\nPath: ${c.path}\n\n${c.content}`)
162
- .join('\n\n---\n\n')
163
-
164
- const systemPrompt = `You are a helpful documentation assistant. Answer questions based ONLY on the provided documentation context. If the answer isn't in the context, say so.
165
-
166
- Always cite your sources by mentioning which document you're referencing. Be concise and helpful.
167
-
168
- Documentation context:
169
- ${contextText}`
170
-
171
- const messages = [
172
- { role: 'system' as const, content: systemPrompt },
173
- ...history.slice(-4), // Last 4 messages for context
174
- { role: 'user' as const, content: query },
175
- ]
176
-
177
- try {
178
- let answer: string
179
-
180
- if (process.env.OPENAI_API_KEY) {
181
- const response = await fetch('https://api.openai.com/v1/chat/completions', {
182
- method: 'POST',
183
- headers: {
184
- 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
185
- 'Content-Type': 'application/json',
186
- },
187
- body: JSON.stringify({
188
- model: 'gpt-4o-mini',
189
- messages,
190
- max_tokens: 500,
191
- }),
192
- })
193
-
194
- const data = await response.json()
195
- answer = data.choices?.[0]?.message?.content || 'Sorry, I could not generate an answer.'
196
- } else {
197
- const response = await fetch('https://api.anthropic.com/v1/messages', {
198
- method: 'POST',
199
- headers: {
200
- 'x-api-key': process.env.ANTHROPIC_API_KEY!,
201
- 'anthropic-version': '2023-06-01',
202
- 'Content-Type': 'application/json',
203
- },
204
- body: JSON.stringify({
205
- model: 'claude-sonnet-4-20250514',
206
- max_tokens: 500,
207
- system: systemPrompt,
208
- messages: messages.filter(m => m.role !== 'system'),
209
- }),
210
- })
211
-
212
- const data = await response.json()
213
- answer = data.content?.[0]?.text || 'Sorry, I could not generate an answer.'
214
- }
215
-
216
- return {
217
- content: answer,
218
- citations: context.slice(0, 3).map(c => ({
219
- title: c.title,
220
- path: `/docs${c.path}`,
221
- snippet: c.content.slice(0, 100),
222
- })),
223
- }
224
- } catch (err) {
225
- console.error('AI generation error:', err)
226
- return {
227
- content: 'Sorry, I encountered an error generating an answer. Please try again.',
228
- citations: [],
229
- }
144
+ if (process.env.ANTHROPIC_API_KEY) {
145
+ const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
146
+ return anthropic('claude-sonnet-4-20250514')
230
147
  }
148
+ return null
231
149
  }
232
150
 
233
151
  export async function POST(request: Request) {
152
+ const ip = request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? 'unknown'
153
+ if (!checkRateLimit(ip)) {
154
+ return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 })
155
+ }
156
+
234
157
  try {
235
158
  const body = await request.json()
236
- const { messages } = body as { messages: Message[] }
159
+ const { messages } = body as { messages: CoreMessage[] }
237
160
 
238
161
  if (!Array.isArray(messages) || messages.length === 0 || messages.length > 50) {
239
- return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
162
+ return new Response(JSON.stringify({ error: 'Invalid request' }), { status: 400 })
240
163
  }
241
164
 
242
165
  const lastMessage = messages[messages.length - 1]
243
166
  if (typeof lastMessage?.content !== 'string' || lastMessage.content.length > 10000) {
244
- return NextResponse.json({ error: 'Message too long' }, { status: 400 })
167
+ return new Response(JSON.stringify({ error: 'Message too long' }), { status: 400 })
245
168
  }
246
169
 
247
- if (lastMessage.role !== 'user') {
248
- return NextResponse.json({ error: 'Last message must be from user' }, { status: 400 })
170
+ // Index and search docs
171
+ const chunks = indexDocs()
172
+ const relevantDocs = searchDocs(lastMessage.content as string, chunks)
173
+
174
+ const contextText = relevantDocs
175
+ .map(c => `## ${c.title}\nPath: ${c.path}\n\n${c.content}`)
176
+ .join('\n\n---\n\n')
177
+
178
+ const citations = relevantDocs.slice(0, 3).map(c => ({
179
+ title: c.title,
180
+ path: `/docs${c.path}`,
181
+ snippet: c.content.slice(0, 100),
182
+ }))
183
+
184
+ const model = getModel()
185
+
186
+ // No API key — return plain search results as a non-streaming response
187
+ if (!model) {
188
+ const fallback = relevantDocs.length > 0
189
+ ? `Based on the docs:\n\n${relevantDocs[0].content.slice(0, 500)}...\n\nSee the linked sources for more.`
190
+ : "I couldn't find relevant documentation. Try rephrasing or browse the docs directly."
191
+
192
+ return new Response(JSON.stringify({ content: fallback, citations }), {
193
+ headers: { 'Content-Type': 'application/json' },
194
+ })
249
195
  }
250
196
 
251
- // Index docs
252
- const chunks = indexDocs()
197
+ // Stream the response using Vercel AI SDK
198
+ const result = streamText({
199
+ model,
200
+ system: `You are a helpful documentation assistant. Answer questions based ONLY on the provided documentation context. If the answer isn't in the context, say so. Be concise. Use markdown formatting (bold, code blocks, lists) to make answers scannable.
253
201
 
254
- // Search for relevant docs
255
- const relevantDocs = searchDocs(lastMessage.content, chunks)
202
+ Documentation context:
203
+ ${contextText}
256
204
 
257
- // Generate answer
258
- const answer = await generateAnswer(lastMessage.content, relevantDocs, messages.slice(0, -1))
205
+ Sources available (reference these when relevant):
206
+ ${citations.map(c => `- [${c.title}](${c.path})`).join('\n')}`,
207
+ messages: messages.slice(-5) as CoreMessage[],
208
+ maxTokens: 800,
209
+ })
259
210
 
260
- return NextResponse.json(answer)
211
+ return result.toDataStreamResponse({
212
+ headers: {
213
+ 'X-Citations': Buffer.from(JSON.stringify(citations)).toString('base64'),
214
+ },
215
+ })
261
216
  } catch (err) {
262
217
  console.error('Chat error:', err)
263
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
218
+ return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500 })
264
219
  }
265
220
  }