glancey 2.0.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 (336) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +435 -0
  3. package/dist/__tests__/ast-chunker.test.d.ts +2 -0
  4. package/dist/__tests__/ast-chunker.test.d.ts.map +1 -0
  5. package/dist/__tests__/ast-chunker.test.js +307 -0
  6. package/dist/__tests__/ast-chunker.test.js.map +1 -0
  7. package/dist/__tests__/config.test.d.ts +2 -0
  8. package/dist/__tests__/config.test.d.ts.map +1 -0
  9. package/dist/__tests__/config.test.js +242 -0
  10. package/dist/__tests__/config.test.js.map +1 -0
  11. package/dist/__tests__/dashboard/beads.test.d.ts +2 -0
  12. package/dist/__tests__/dashboard/beads.test.d.ts.map +1 -0
  13. package/dist/__tests__/dashboard/beads.test.js +151 -0
  14. package/dist/__tests__/dashboard/beads.test.js.map +1 -0
  15. package/dist/__tests__/dashboard/index.test.d.ts +2 -0
  16. package/dist/__tests__/dashboard/index.test.d.ts.map +1 -0
  17. package/dist/__tests__/dashboard/index.test.js +116 -0
  18. package/dist/__tests__/dashboard/index.test.js.map +1 -0
  19. package/dist/__tests__/dashboard/routes.test.d.ts +2 -0
  20. package/dist/__tests__/dashboard/routes.test.d.ts.map +1 -0
  21. package/dist/__tests__/dashboard/routes.test.js +125 -0
  22. package/dist/__tests__/dashboard/routes.test.js.map +1 -0
  23. package/dist/__tests__/dashboard/server.test.d.ts +2 -0
  24. package/dist/__tests__/dashboard/server.test.d.ts.map +1 -0
  25. package/dist/__tests__/dashboard/server.test.js +75 -0
  26. package/dist/__tests__/dashboard/server.test.js.map +1 -0
  27. package/dist/__tests__/dashboard/state.test.d.ts +2 -0
  28. package/dist/__tests__/dashboard/state.test.d.ts.map +1 -0
  29. package/dist/__tests__/dashboard/state.test.js +124 -0
  30. package/dist/__tests__/dashboard/state.test.js.map +1 -0
  31. package/dist/__tests__/dashboard/token-tracking.test.d.ts +2 -0
  32. package/dist/__tests__/dashboard/token-tracking.test.d.ts.map +1 -0
  33. package/dist/__tests__/dashboard/token-tracking.test.js +315 -0
  34. package/dist/__tests__/dashboard/token-tracking.test.js.map +1 -0
  35. package/dist/__tests__/embeddings/factory.test.d.ts +2 -0
  36. package/dist/__tests__/embeddings/factory.test.d.ts.map +1 -0
  37. package/dist/__tests__/embeddings/factory.test.js +157 -0
  38. package/dist/__tests__/embeddings/factory.test.js.map +1 -0
  39. package/dist/__tests__/embeddings/gemini.test.d.ts +2 -0
  40. package/dist/__tests__/embeddings/gemini.test.d.ts.map +1 -0
  41. package/dist/__tests__/embeddings/gemini.test.js +178 -0
  42. package/dist/__tests__/embeddings/gemini.test.js.map +1 -0
  43. package/dist/__tests__/embeddings/ollama.test.d.ts +2 -0
  44. package/dist/__tests__/embeddings/ollama.test.d.ts.map +1 -0
  45. package/dist/__tests__/embeddings/ollama.test.js +273 -0
  46. package/dist/__tests__/embeddings/ollama.test.js.map +1 -0
  47. package/dist/__tests__/embeddings/rate-limiter.test.d.ts +2 -0
  48. package/dist/__tests__/embeddings/rate-limiter.test.d.ts.map +1 -0
  49. package/dist/__tests__/embeddings/rate-limiter.test.js +163 -0
  50. package/dist/__tests__/embeddings/rate-limiter.test.js.map +1 -0
  51. package/dist/__tests__/embeddings/retry.test.d.ts +2 -0
  52. package/dist/__tests__/embeddings/retry.test.d.ts.map +1 -0
  53. package/dist/__tests__/embeddings/retry.test.js +334 -0
  54. package/dist/__tests__/embeddings/retry.test.js.map +1 -0
  55. package/dist/__tests__/embeddings/types.test.d.ts +2 -0
  56. package/dist/__tests__/embeddings/types.test.d.ts.map +1 -0
  57. package/dist/__tests__/embeddings/types.test.js +31 -0
  58. package/dist/__tests__/embeddings/types.test.js.map +1 -0
  59. package/dist/__tests__/mocks/embedding-backend.mock.d.ts +10 -0
  60. package/dist/__tests__/mocks/embedding-backend.mock.d.ts.map +1 -0
  61. package/dist/__tests__/mocks/embedding-backend.mock.js +39 -0
  62. package/dist/__tests__/mocks/embedding-backend.mock.js.map +1 -0
  63. package/dist/__tests__/mocks/fetch.mock.d.ts +49 -0
  64. package/dist/__tests__/mocks/fetch.mock.d.ts.map +1 -0
  65. package/dist/__tests__/mocks/fetch.mock.js +102 -0
  66. package/dist/__tests__/mocks/fetch.mock.js.map +1 -0
  67. package/dist/__tests__/mocks/lancedb.mock.d.ts +38 -0
  68. package/dist/__tests__/mocks/lancedb.mock.d.ts.map +1 -0
  69. package/dist/__tests__/mocks/lancedb.mock.js +63 -0
  70. package/dist/__tests__/mocks/lancedb.mock.js.map +1 -0
  71. package/dist/__tests__/search/clustering.test.d.ts +2 -0
  72. package/dist/__tests__/search/clustering.test.d.ts.map +1 -0
  73. package/dist/__tests__/search/clustering.test.js +230 -0
  74. package/dist/__tests__/search/clustering.test.js.map +1 -0
  75. package/dist/__tests__/search/hybrid-search.test.d.ts +2 -0
  76. package/dist/__tests__/search/hybrid-search.test.d.ts.map +1 -0
  77. package/dist/__tests__/search/hybrid-search.test.js +279 -0
  78. package/dist/__tests__/search/hybrid-search.test.js.map +1 -0
  79. package/dist/__tests__/search/indexer.test.d.ts +2 -0
  80. package/dist/__tests__/search/indexer.test.d.ts.map +1 -0
  81. package/dist/__tests__/search/indexer.test.js +1305 -0
  82. package/dist/__tests__/search/indexer.test.js.map +1 -0
  83. package/dist/__tests__/search/tree-sitter-chunker.test.d.ts +2 -0
  84. package/dist/__tests__/search/tree-sitter-chunker.test.d.ts.map +1 -0
  85. package/dist/__tests__/search/tree-sitter-chunker.test.js +228 -0
  86. package/dist/__tests__/search/tree-sitter-chunker.test.js.map +1 -0
  87. package/dist/__tests__/setup.d.ts +2 -0
  88. package/dist/__tests__/setup.d.ts.map +1 -0
  89. package/dist/__tests__/setup.js +11 -0
  90. package/dist/__tests__/setup.js.map +1 -0
  91. package/dist/__tests__/tools/clustering-handlers.test.d.ts +2 -0
  92. package/dist/__tests__/tools/clustering-handlers.test.d.ts.map +1 -0
  93. package/dist/__tests__/tools/clustering-handlers.test.js +286 -0
  94. package/dist/__tests__/tools/clustering-handlers.test.js.map +1 -0
  95. package/dist/__tests__/tools/commit-handlers.test.d.ts +2 -0
  96. package/dist/__tests__/tools/commit-handlers.test.d.ts.map +1 -0
  97. package/dist/__tests__/tools/commit-handlers.test.js +153 -0
  98. package/dist/__tests__/tools/commit-handlers.test.js.map +1 -0
  99. package/dist/__tests__/tools/index-handlers.test.d.ts +2 -0
  100. package/dist/__tests__/tools/index-handlers.test.d.ts.map +1 -0
  101. package/dist/__tests__/tools/index-handlers.test.js +184 -0
  102. package/dist/__tests__/tools/index-handlers.test.js.map +1 -0
  103. package/dist/__tests__/tools/instructions-handlers.test.d.ts +2 -0
  104. package/dist/__tests__/tools/instructions-handlers.test.d.ts.map +1 -0
  105. package/dist/__tests__/tools/instructions-handlers.test.js +53 -0
  106. package/dist/__tests__/tools/instructions-handlers.test.js.map +1 -0
  107. package/dist/__tests__/tools/memory-handlers.test.d.ts +2 -0
  108. package/dist/__tests__/tools/memory-handlers.test.d.ts.map +1 -0
  109. package/dist/__tests__/tools/memory-handlers.test.js +175 -0
  110. package/dist/__tests__/tools/memory-handlers.test.js.map +1 -0
  111. package/dist/__tests__/tools/search-handlers.test.d.ts +2 -0
  112. package/dist/__tests__/tools/search-handlers.test.d.ts.map +1 -0
  113. package/dist/__tests__/tools/search-handlers.test.js +229 -0
  114. package/dist/__tests__/tools/search-handlers.test.js.map +1 -0
  115. package/dist/__tests__/tools/symbol-handlers.test.d.ts +2 -0
  116. package/dist/__tests__/tools/symbol-handlers.test.d.ts.map +1 -0
  117. package/dist/__tests__/tools/symbol-handlers.test.js +348 -0
  118. package/dist/__tests__/tools/symbol-handlers.test.js.map +1 -0
  119. package/dist/__tests__/tools/worktree-handlers.test.d.ts +2 -0
  120. package/dist/__tests__/tools/worktree-handlers.test.d.ts.map +1 -0
  121. package/dist/__tests__/tools/worktree-handlers.test.js +297 -0
  122. package/dist/__tests__/tools/worktree-handlers.test.js.map +1 -0
  123. package/dist/__tests__/utils/cache.test.d.ts +2 -0
  124. package/dist/__tests__/utils/cache.test.d.ts.map +1 -0
  125. package/dist/__tests__/utils/cache.test.js +147 -0
  126. package/dist/__tests__/utils/cache.test.js.map +1 -0
  127. package/dist/__tests__/utils/concurrency.test.d.ts +2 -0
  128. package/dist/__tests__/utils/concurrency.test.d.ts.map +1 -0
  129. package/dist/__tests__/utils/concurrency.test.js +83 -0
  130. package/dist/__tests__/utils/concurrency.test.js.map +1 -0
  131. package/dist/__tests__/utils/errors.test.d.ts +2 -0
  132. package/dist/__tests__/utils/errors.test.d.ts.map +1 -0
  133. package/dist/__tests__/utils/errors.test.js +136 -0
  134. package/dist/__tests__/utils/errors.test.js.map +1 -0
  135. package/dist/__tests__/utils/logger.test.d.ts +2 -0
  136. package/dist/__tests__/utils/logger.test.d.ts.map +1 -0
  137. package/dist/__tests__/utils/logger.test.js +131 -0
  138. package/dist/__tests__/utils/logger.test.js.map +1 -0
  139. package/dist/__tests__/utils/type-guards.test.d.ts +2 -0
  140. package/dist/__tests__/utils/type-guards.test.d.ts.map +1 -0
  141. package/dist/__tests__/utils/type-guards.test.js +80 -0
  142. package/dist/__tests__/utils/type-guards.test.js.map +1 -0
  143. package/dist/__tests__/worktree/worktree-manager.test.d.ts +2 -0
  144. package/dist/__tests__/worktree/worktree-manager.test.d.ts.map +1 -0
  145. package/dist/__tests__/worktree/worktree-manager.test.js +403 -0
  146. package/dist/__tests__/worktree/worktree-manager.test.js.map +1 -0
  147. package/dist/config.d.ts +191 -0
  148. package/dist/config.d.ts.map +1 -0
  149. package/dist/config.js +788 -0
  150. package/dist/config.js.map +1 -0
  151. package/dist/dashboard/beads.d.ts +35 -0
  152. package/dist/dashboard/beads.d.ts.map +1 -0
  153. package/dist/dashboard/beads.js +102 -0
  154. package/dist/dashboard/beads.js.map +1 -0
  155. package/dist/dashboard/events.d.ts +64 -0
  156. package/dist/dashboard/events.d.ts.map +1 -0
  157. package/dist/dashboard/events.js +211 -0
  158. package/dist/dashboard/events.js.map +1 -0
  159. package/dist/dashboard/index.d.ts +69 -0
  160. package/dist/dashboard/index.d.ts.map +1 -0
  161. package/dist/dashboard/index.js +93 -0
  162. package/dist/dashboard/index.js.map +1 -0
  163. package/dist/dashboard/routes.d.ts +6 -0
  164. package/dist/dashboard/routes.d.ts.map +1 -0
  165. package/dist/dashboard/routes.js +505 -0
  166. package/dist/dashboard/routes.js.map +1 -0
  167. package/dist/dashboard/server.d.ts +27 -0
  168. package/dist/dashboard/server.d.ts.map +1 -0
  169. package/dist/dashboard/server.js +72 -0
  170. package/dist/dashboard/server.js.map +1 -0
  171. package/dist/dashboard/state.d.ts +172 -0
  172. package/dist/dashboard/state.d.ts.map +1 -0
  173. package/dist/dashboard/state.js +362 -0
  174. package/dist/dashboard/state.js.map +1 -0
  175. package/dist/dashboard/token-tracking.d.ts +143 -0
  176. package/dist/dashboard/token-tracking.d.ts.map +1 -0
  177. package/dist/dashboard/token-tracking.js +363 -0
  178. package/dist/dashboard/token-tracking.js.map +1 -0
  179. package/dist/dashboard/ui.d.ts +6 -0
  180. package/dist/dashboard/ui.d.ts.map +1 -0
  181. package/dist/dashboard/ui.js +2710 -0
  182. package/dist/dashboard/ui.js.map +1 -0
  183. package/dist/embeddings/gemini.d.ts +28 -0
  184. package/dist/embeddings/gemini.d.ts.map +1 -0
  185. package/dist/embeddings/gemini.js +165 -0
  186. package/dist/embeddings/gemini.js.map +1 -0
  187. package/dist/embeddings/index.d.ts +34 -0
  188. package/dist/embeddings/index.d.ts.map +1 -0
  189. package/dist/embeddings/index.js +120 -0
  190. package/dist/embeddings/index.js.map +1 -0
  191. package/dist/embeddings/ollama.d.ts +22 -0
  192. package/dist/embeddings/ollama.d.ts.map +1 -0
  193. package/dist/embeddings/ollama.js +179 -0
  194. package/dist/embeddings/ollama.js.map +1 -0
  195. package/dist/embeddings/rate-limiter.d.ts +75 -0
  196. package/dist/embeddings/rate-limiter.d.ts.map +1 -0
  197. package/dist/embeddings/rate-limiter.js +145 -0
  198. package/dist/embeddings/rate-limiter.js.map +1 -0
  199. package/dist/embeddings/retry.d.ts +20 -0
  200. package/dist/embeddings/retry.d.ts.map +1 -0
  201. package/dist/embeddings/retry.js +227 -0
  202. package/dist/embeddings/retry.js.map +1 -0
  203. package/dist/embeddings/types.d.ts +103 -0
  204. package/dist/embeddings/types.d.ts.map +1 -0
  205. package/dist/embeddings/types.js +23 -0
  206. package/dist/embeddings/types.js.map +1 -0
  207. package/dist/index.d.ts +12 -0
  208. package/dist/index.d.ts.map +1 -0
  209. package/dist/index.js +2028 -0
  210. package/dist/index.js.map +1 -0
  211. package/dist/memory/index.d.ts +63 -0
  212. package/dist/memory/index.d.ts.map +1 -0
  213. package/dist/memory/index.js +168 -0
  214. package/dist/memory/index.js.map +1 -0
  215. package/dist/search/ast-chunker.d.ts +29 -0
  216. package/dist/search/ast-chunker.d.ts.map +1 -0
  217. package/dist/search/ast-chunker.js +236 -0
  218. package/dist/search/ast-chunker.js.map +1 -0
  219. package/dist/search/chunk-utils.d.ts +24 -0
  220. package/dist/search/chunk-utils.d.ts.map +1 -0
  221. package/dist/search/chunk-utils.js +41 -0
  222. package/dist/search/chunk-utils.js.map +1 -0
  223. package/dist/search/clustering.d.ts +77 -0
  224. package/dist/search/clustering.d.ts.map +1 -0
  225. package/dist/search/clustering.js +455 -0
  226. package/dist/search/clustering.js.map +1 -0
  227. package/dist/search/indexer.d.ts +377 -0
  228. package/dist/search/indexer.d.ts.map +1 -0
  229. package/dist/search/indexer.js +1557 -0
  230. package/dist/search/indexer.js.map +1 -0
  231. package/dist/search/tree-sitter-chunker.d.ts +64 -0
  232. package/dist/search/tree-sitter-chunker.d.ts.map +1 -0
  233. package/dist/search/tree-sitter-chunker.js +412 -0
  234. package/dist/search/tree-sitter-chunker.js.map +1 -0
  235. package/dist/symbols/index.d.ts +14 -0
  236. package/dist/symbols/index.d.ts.map +1 -0
  237. package/dist/symbols/index.js +19 -0
  238. package/dist/symbols/index.js.map +1 -0
  239. package/dist/symbols/name-path.d.ts +113 -0
  240. package/dist/symbols/name-path.d.ts.map +1 -0
  241. package/dist/symbols/name-path.js +194 -0
  242. package/dist/symbols/name-path.js.map +1 -0
  243. package/dist/symbols/pattern-search.d.ts +14 -0
  244. package/dist/symbols/pattern-search.d.ts.map +1 -0
  245. package/dist/symbols/pattern-search.js +224 -0
  246. package/dist/symbols/pattern-search.js.map +1 -0
  247. package/dist/symbols/reference-finder.d.ts +38 -0
  248. package/dist/symbols/reference-finder.d.ts.map +1 -0
  249. package/dist/symbols/reference-finder.js +376 -0
  250. package/dist/symbols/reference-finder.js.map +1 -0
  251. package/dist/symbols/symbol-editor.d.ts +81 -0
  252. package/dist/symbols/symbol-editor.d.ts.map +1 -0
  253. package/dist/symbols/symbol-editor.js +257 -0
  254. package/dist/symbols/symbol-editor.js.map +1 -0
  255. package/dist/symbols/symbol-extractor.d.ts +49 -0
  256. package/dist/symbols/symbol-extractor.d.ts.map +1 -0
  257. package/dist/symbols/symbol-extractor.js +593 -0
  258. package/dist/symbols/symbol-extractor.js.map +1 -0
  259. package/dist/symbols/symbol-renamer.d.ts +81 -0
  260. package/dist/symbols/symbol-renamer.d.ts.map +1 -0
  261. package/dist/symbols/symbol-renamer.js +204 -0
  262. package/dist/symbols/symbol-renamer.js.map +1 -0
  263. package/dist/symbols/types.d.ts +234 -0
  264. package/dist/symbols/types.d.ts.map +1 -0
  265. package/dist/symbols/types.js +106 -0
  266. package/dist/symbols/types.js.map +1 -0
  267. package/dist/tools/clustering-handlers.d.ts +70 -0
  268. package/dist/tools/clustering-handlers.d.ts.map +1 -0
  269. package/dist/tools/clustering-handlers.js +150 -0
  270. package/dist/tools/clustering-handlers.js.map +1 -0
  271. package/dist/tools/commit-handlers.d.ts +77 -0
  272. package/dist/tools/commit-handlers.d.ts.map +1 -0
  273. package/dist/tools/commit-handlers.js +251 -0
  274. package/dist/tools/commit-handlers.js.map +1 -0
  275. package/dist/tools/index-handlers.d.ts +31 -0
  276. package/dist/tools/index-handlers.d.ts.map +1 -0
  277. package/dist/tools/index-handlers.js +53 -0
  278. package/dist/tools/index-handlers.js.map +1 -0
  279. package/dist/tools/instructions-handlers.d.ts +25 -0
  280. package/dist/tools/instructions-handlers.d.ts.map +1 -0
  281. package/dist/tools/instructions-handlers.js +36 -0
  282. package/dist/tools/instructions-handlers.js.map +1 -0
  283. package/dist/tools/memory-handlers.d.ts +95 -0
  284. package/dist/tools/memory-handlers.d.ts.map +1 -0
  285. package/dist/tools/memory-handlers.js +126 -0
  286. package/dist/tools/memory-handlers.js.map +1 -0
  287. package/dist/tools/search-handlers.d.ts +51 -0
  288. package/dist/tools/search-handlers.d.ts.map +1 -0
  289. package/dist/tools/search-handlers.js +128 -0
  290. package/dist/tools/search-handlers.js.map +1 -0
  291. package/dist/tools/symbol-handlers.d.ts +171 -0
  292. package/dist/tools/symbol-handlers.d.ts.map +1 -0
  293. package/dist/tools/symbol-handlers.js +456 -0
  294. package/dist/tools/symbol-handlers.js.map +1 -0
  295. package/dist/tools/types.d.ts +32 -0
  296. package/dist/tools/types.d.ts.map +1 -0
  297. package/dist/tools/types.js +17 -0
  298. package/dist/tools/types.js.map +1 -0
  299. package/dist/tools/worktree-handlers.d.ts +96 -0
  300. package/dist/tools/worktree-handlers.d.ts.map +1 -0
  301. package/dist/tools/worktree-handlers.js +186 -0
  302. package/dist/tools/worktree-handlers.js.map +1 -0
  303. package/dist/utils/cache.d.ts +77 -0
  304. package/dist/utils/cache.d.ts.map +1 -0
  305. package/dist/utils/cache.js +134 -0
  306. package/dist/utils/cache.js.map +1 -0
  307. package/dist/utils/concurrency.d.ts +32 -0
  308. package/dist/utils/concurrency.d.ts.map +1 -0
  309. package/dist/utils/concurrency.js +57 -0
  310. package/dist/utils/concurrency.js.map +1 -0
  311. package/dist/utils/errors.d.ts +36 -0
  312. package/dist/utils/errors.d.ts.map +1 -0
  313. package/dist/utils/errors.js +91 -0
  314. package/dist/utils/errors.js.map +1 -0
  315. package/dist/utils/logger.d.ts +37 -0
  316. package/dist/utils/logger.d.ts.map +1 -0
  317. package/dist/utils/logger.js +114 -0
  318. package/dist/utils/logger.js.map +1 -0
  319. package/dist/utils/type-guards.d.ts +17 -0
  320. package/dist/utils/type-guards.d.ts.map +1 -0
  321. package/dist/utils/type-guards.js +25 -0
  322. package/dist/utils/type-guards.js.map +1 -0
  323. package/dist/worktree/index.d.ts +6 -0
  324. package/dist/worktree/index.d.ts.map +1 -0
  325. package/dist/worktree/index.js +6 -0
  326. package/dist/worktree/index.js.map +1 -0
  327. package/dist/worktree/types.d.ts +101 -0
  328. package/dist/worktree/types.d.ts.map +1 -0
  329. package/dist/worktree/types.js +6 -0
  330. package/dist/worktree/types.js.map +1 -0
  331. package/dist/worktree/worktree-manager.d.ts +80 -0
  332. package/dist/worktree/worktree-manager.d.ts.map +1 -0
  333. package/dist/worktree/worktree-manager.js +407 -0
  334. package/dist/worktree/worktree-manager.js.map +1 -0
  335. package/package.json +87 -0
  336. package/scripts/postinstall.js +48 -0
@@ -0,0 +1,1557 @@
1
+ import * as lancedb from '@lancedb/lancedb';
2
+ import * as crypto from 'crypto';
3
+ import * as fs from 'fs/promises';
4
+ import * as path from 'path';
5
+ import { broadcastLog } from '../dashboard/events.js';
6
+ import { ASTChunker } from './ast-chunker.js';
7
+ import { TreeSitterChunker } from './tree-sitter-chunker.js';
8
+ import { loadConfig, getDefaultPatterns, getDefaultExcludePatterns, getChunkingConfig, getSearchConfig, getIndexingConfig, } from '../config.js';
9
+ import { TTLCache } from '../utils/cache.js';
10
+ import { minimatch } from 'minimatch';
11
+ import { mapInBatches } from '../utils/concurrency.js';
12
+ import { kMeansClustering, calculateSilhouetteScore, } from './clustering.js';
13
+ /** Default concurrency for parallel file processing */
14
+ const FILE_PROCESSING_CONCURRENCY = 10;
15
+ /**
16
+ * Compute a checksum for index integrity validation.
17
+ * Based on sorted file list and chunk count.
18
+ */
19
+ function computeIndexChecksum(files, chunkCount) {
20
+ const sortedFiles = [...files].sort();
21
+ const data = JSON.stringify({ files: sortedFiles, chunkCount });
22
+ return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16);
23
+ }
24
+ /**
25
+ * Sanitize a string for use in LanceDB filter expressions.
26
+ * Prevents SQL injection by escaping quotes and backslashes.
27
+ * Used for filepaths and chunk IDs in WHERE clauses.
28
+ */
29
+ function sanitizeForFilter(value) {
30
+ // Safe characters: alphanumeric, /, ., -, _, space, : (for chunk IDs like "path:1-10")
31
+ // This is more restrictive than escaping and prevents injection attacks
32
+ if (!/^[\w\s./:_-]+$/.test(value)) {
33
+ // If value contains unusual characters, escape single quotes and backslashes
34
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "''");
35
+ }
36
+ return value.replace(/'/g, "''");
37
+ }
38
+ /**
39
+ * Code indexer that uses LanceDB for vector storage and semantic search.
40
+ *
41
+ * Provides functionality to:
42
+ * - Index a codebase by chunking files and generating embeddings
43
+ * - Perform hybrid semantic + keyword search
44
+ * - Support incremental indexing (only re-index changed files)
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * const backend = await createEmbeddingBackend();
49
+ * const indexer = new CodeIndexer('/path/to/project', backend);
50
+ * await indexer.initialize();
51
+ *
52
+ * // Index the codebase
53
+ * await indexer.indexCodebase();
54
+ *
55
+ * // Search for code
56
+ * const results = await indexer.search('authentication middleware');
57
+ * ```
58
+ */
59
+ export class CodeIndexer {
60
+ db = null;
61
+ table = null;
62
+ metadataTable = null;
63
+ embeddingBackend;
64
+ indexPath;
65
+ projectPath;
66
+ config = null;
67
+ /** LRU cache for query embeddings with TTL to avoid recomputing identical queries */
68
+ queryEmbeddingCache = new TTLCache({ maxSize: 100, ttlMs: 60 * 60 * 1000 });
69
+ /** Tracks chunking method usage during current indexing operation */
70
+ currentChunkingStats = this.createEmptyChunkingStats();
71
+ /** Create empty chunking stats */
72
+ createEmptyChunkingStats() {
73
+ return {
74
+ astChunked: 0,
75
+ treeSitterChunked: 0,
76
+ lineBasedChunked: 0,
77
+ astFallbacks: [],
78
+ treeSitterFallbacks: [],
79
+ };
80
+ }
81
+ constructor(projectPath, embeddingBackend) {
82
+ this.projectPath = projectPath;
83
+ this.embeddingBackend = embeddingBackend;
84
+ this.indexPath = path.join(projectPath, '.glancey');
85
+ }
86
+ async initialize() {
87
+ this.db = await lancedb.connect(this.indexPath);
88
+ this.config = await loadConfig(this.projectPath);
89
+ console.error(`[glancey] Loaded config with ${this.config.patterns?.length} patterns`);
90
+ }
91
+ get metadataPath() {
92
+ return path.join(this.indexPath, 'index-metadata.json');
93
+ }
94
+ get checkpointPath() {
95
+ return path.join(this.indexPath, 'checkpoint.json');
96
+ }
97
+ /**
98
+ * Save indexing checkpoint to disk for crash recovery.
99
+ * Strips chunk content to reduce checkpoint size - content is re-read on resume.
100
+ */
101
+ async saveCheckpoint(checkpoint, options = {}) {
102
+ const { blocking = false } = options;
103
+ const doSave = async () => {
104
+ await fs.mkdir(this.indexPath, { recursive: true });
105
+ // Strip content from chunks to reduce checkpoint size (can be 50MB+ for large codebases)
106
+ // Content will be re-read from files on checkpoint resume
107
+ const lightCheckpoint = {
108
+ ...checkpoint,
109
+ pendingChunks: checkpoint.pendingChunks?.map((c) => ({ ...c, content: '' })),
110
+ embeddedChunks: checkpoint.embeddedChunks?.map((c) => ({ ...c, content: '' })),
111
+ };
112
+ await fs.writeFile(this.checkpointPath, JSON.stringify(lightCheckpoint));
113
+ };
114
+ if (blocking) {
115
+ await doSave();
116
+ }
117
+ else {
118
+ // Non-blocking: start write in background, don't wait
119
+ // If crash happens before write completes, checkpoint is lost but that's acceptable
120
+ doSave().catch((err) => console.error('[glancey] Checkpoint save failed:', err));
121
+ }
122
+ }
123
+ /**
124
+ * Re-read chunk content from source files.
125
+ * Used when resuming from a checkpoint that has stripped content.
126
+ */
127
+ async rehydrateChunkContent(chunks) {
128
+ // Group chunks by file for efficient reading
129
+ const chunksByFile = new Map();
130
+ for (const chunk of chunks) {
131
+ const existing = chunksByFile.get(chunk.filepath) || [];
132
+ existing.push(chunk);
133
+ chunksByFile.set(chunk.filepath, existing);
134
+ }
135
+ // Read each file once and populate all its chunks
136
+ await Promise.all(Array.from(chunksByFile.entries()).map(async ([filepath, fileChunks]) => {
137
+ try {
138
+ const fullPath = path.join(this.projectPath, filepath);
139
+ const fileContent = await fs.readFile(fullPath, 'utf-8');
140
+ const lines = fileContent.split('\n');
141
+ for (const chunk of fileChunks) {
142
+ chunk.content = lines.slice(chunk.startLine - 1, chunk.endLine).join('\n');
143
+ }
144
+ }
145
+ catch {
146
+ // File may have been deleted - mark chunk for removal
147
+ for (const chunk of fileChunks) {
148
+ chunk.content = '';
149
+ }
150
+ }
151
+ }));
152
+ }
153
+ /**
154
+ * Load indexing checkpoint from disk.
155
+ * Returns null if no checkpoint exists or if it's invalid.
156
+ */
157
+ async loadCheckpoint() {
158
+ try {
159
+ const content = await fs.readFile(this.checkpointPath, 'utf-8');
160
+ const checkpoint = JSON.parse(content);
161
+ // Validate checkpoint has required fields
162
+ if (!checkpoint.phase ||
163
+ !checkpoint.startedAt ||
164
+ !Array.isArray(checkpoint.files) ||
165
+ !Array.isArray(checkpoint.processedFiles)) {
166
+ console.error('[glancey] Invalid checkpoint file, ignoring');
167
+ await this.clearCheckpoint();
168
+ return null;
169
+ }
170
+ return checkpoint;
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ /**
177
+ * Clear the indexing checkpoint file.
178
+ */
179
+ async clearCheckpoint() {
180
+ try {
181
+ await fs.unlink(this.checkpointPath);
182
+ }
183
+ catch {
184
+ // Ignore errors if file doesn't exist
185
+ }
186
+ }
187
+ /**
188
+ * Save index metadata to disk
189
+ */
190
+ async saveIndexMetadata(fileCount, chunkCount, indexedFiles) {
191
+ // Convert to relative paths for checksum
192
+ const relativePaths = indexedFiles.map((f) => path.isAbsolute(f) ? path.relative(this.projectPath, f) : f);
193
+ const metadata = {
194
+ lastUpdated: new Date().toISOString(),
195
+ fileCount,
196
+ chunkCount,
197
+ embeddingBackend: this.embeddingBackend.name,
198
+ embeddingModel: this.embeddingBackend.getModel(),
199
+ embeddingDimensions: this.embeddingBackend.getDimensions(),
200
+ version: '1.0.0',
201
+ checksum: computeIndexChecksum(relativePaths, chunkCount),
202
+ chunkingStats: this.currentChunkingStats,
203
+ };
204
+ await fs.writeFile(this.metadataPath, JSON.stringify(metadata, null, 2));
205
+ }
206
+ /**
207
+ * Load index metadata from disk
208
+ */
209
+ async loadIndexMetadata() {
210
+ try {
211
+ const content = await fs.readFile(this.metadataPath, 'utf-8');
212
+ return JSON.parse(content);
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ }
218
+ /**
219
+ * Get the modification time of a file
220
+ */
221
+ async getFileMtime(filepath) {
222
+ const stats = await fs.stat(filepath);
223
+ return stats.mtimeMs;
224
+ }
225
+ /**
226
+ * Collect modification times for multiple files.
227
+ * Used for checkpoint freshness validation.
228
+ */
229
+ async collectFileMtimes(files) {
230
+ const mtimes = {};
231
+ // Process in batches to avoid overwhelming the system with file descriptors
232
+ const batchSize = 500;
233
+ for (let i = 0; i < files.length; i += batchSize) {
234
+ const batch = files.slice(i, i + batchSize);
235
+ const results = await Promise.all(batch.map(async (filepath) => {
236
+ try {
237
+ const relativePath = path.relative(this.projectPath, filepath);
238
+ const mtime = await this.getFileMtime(filepath);
239
+ return { relativePath, mtime };
240
+ }
241
+ catch {
242
+ return null; // Skip files that can't be stat'd
243
+ }
244
+ }));
245
+ for (const result of results) {
246
+ if (result) {
247
+ mtimes[result.relativePath] = result.mtime;
248
+ }
249
+ }
250
+ }
251
+ return mtimes;
252
+ }
253
+ /**
254
+ * Validate that checkpoint files haven't been modified since checkpoint creation.
255
+ * Returns true if checkpoint is fresh, false if files have changed.
256
+ */
257
+ async validateCheckpointFreshness(checkpoint) {
258
+ if (!checkpoint.fileMtimes) {
259
+ // Legacy checkpoint without mtimes - can't validate, assume stale for safety
260
+ return false;
261
+ }
262
+ for (const [relativePath, savedMtime] of Object.entries(checkpoint.fileMtimes)) {
263
+ try {
264
+ const filepath = path.join(this.projectPath, relativePath);
265
+ const currentMtime = await this.getFileMtime(filepath);
266
+ if (currentMtime > savedMtime) {
267
+ console.error(`[glancey] File ${relativePath} modified since checkpoint, invalidating`);
268
+ return false;
269
+ }
270
+ }
271
+ catch {
272
+ // File was deleted or can't be accessed - checkpoint is stale
273
+ console.error(`[glancey] File ${relativePath} no longer accessible, invalidating checkpoint`);
274
+ return false;
275
+ }
276
+ }
277
+ return true;
278
+ }
279
+ /**
280
+ * Get stored metadata for all indexed files
281
+ */
282
+ async getStoredMetadata() {
283
+ const tableNames = await this.db.tableNames();
284
+ if (!tableNames.includes('file_metadata')) {
285
+ return new Map();
286
+ }
287
+ this.metadataTable = await this.db.openTable('file_metadata');
288
+ const rows = await this.metadataTable.query().toArray();
289
+ const metadata = new Map();
290
+ for (const row of rows) {
291
+ metadata.set(row.filepath, row.mtime);
292
+ }
293
+ return metadata;
294
+ }
295
+ /**
296
+ * Detect which files have been added, modified, or deleted
297
+ */
298
+ async detectFileChanges(currentFiles) {
299
+ const storedMetadata = await this.getStoredMetadata();
300
+ const changes = {
301
+ added: [],
302
+ modified: [],
303
+ deleted: [],
304
+ unchanged: [],
305
+ };
306
+ const currentFilesSet = new Set();
307
+ for (const filepath of currentFiles) {
308
+ const relativePath = path.relative(this.projectPath, filepath);
309
+ currentFilesSet.add(relativePath);
310
+ const currentMtime = await this.getFileMtime(filepath);
311
+ const storedMtime = storedMetadata.get(relativePath);
312
+ if (storedMtime === undefined) {
313
+ changes.added.push(filepath);
314
+ }
315
+ else if (currentMtime > storedMtime) {
316
+ changes.modified.push(filepath);
317
+ }
318
+ else {
319
+ changes.unchanged.push(filepath);
320
+ }
321
+ }
322
+ // Find deleted files
323
+ for (const [relativePath] of storedMetadata) {
324
+ if (!currentFilesSet.has(relativePath)) {
325
+ changes.deleted.push(relativePath);
326
+ }
327
+ }
328
+ return changes;
329
+ }
330
+ /**
331
+ * Get all project files matching the configured patterns.
332
+ * Used for change detection and staleness checking.
333
+ */
334
+ async getProjectFiles() {
335
+ const { glob } = await import('glob');
336
+ // Ensure config is loaded
337
+ if (!this.config) {
338
+ this.config = await loadConfig(this.projectPath);
339
+ }
340
+ const patterns = this.config.patterns || getDefaultPatterns();
341
+ const excludePatterns = this.config.excludePatterns || getDefaultExcludePatterns();
342
+ const files = [];
343
+ for (const pattern of patterns) {
344
+ const matches = await glob(pattern, {
345
+ cwd: this.projectPath,
346
+ ignore: excludePatterns,
347
+ absolute: true,
348
+ });
349
+ files.push(...matches);
350
+ }
351
+ return files;
352
+ }
353
+ /**
354
+ * Check if the index is stale (files have been modified since last index).
355
+ * Returns true if any files have been added, modified, or deleted.
356
+ * This is a lightweight check that only compares file modification times.
357
+ */
358
+ async checkIfStale() {
359
+ // Ensure database is initialized
360
+ if (!this.db) {
361
+ await this.initialize();
362
+ }
363
+ // Check if index exists
364
+ const tableNames = await this.db.tableNames();
365
+ if (!tableNames.includes('code_chunks')) {
366
+ return { stale: true, reason: 'Index does not exist' };
367
+ }
368
+ // Check if metadata table exists
369
+ if (!tableNames.includes('file_metadata')) {
370
+ return { stale: true, reason: 'No file metadata stored' };
371
+ }
372
+ // Get current files
373
+ const currentFiles = await this.getProjectFiles();
374
+ // Detect changes
375
+ const changes = await this.detectFileChanges(currentFiles);
376
+ const hasChanges = changes.added.length > 0 || changes.modified.length > 0 || changes.deleted.length > 0;
377
+ if (!hasChanges) {
378
+ return { stale: false };
379
+ }
380
+ // Build reason string
381
+ const reasons = [];
382
+ if (changes.added.length > 0) {
383
+ reasons.push(`${changes.added.length} new file(s)`);
384
+ }
385
+ if (changes.modified.length > 0) {
386
+ reasons.push(`${changes.modified.length} modified file(s)`);
387
+ }
388
+ if (changes.deleted.length > 0) {
389
+ reasons.push(`${changes.deleted.length} deleted file(s)`);
390
+ }
391
+ return { stale: true, reason: reasons.join(', ') };
392
+ }
393
+ /**
394
+ * Save metadata for indexed files
395
+ */
396
+ async saveFileMetadata(files) {
397
+ const metadata = [];
398
+ for (const filepath of files) {
399
+ const relativePath = path.relative(this.projectPath, filepath);
400
+ const mtime = await this.getFileMtime(filepath);
401
+ metadata.push({ filepath: relativePath, mtime });
402
+ }
403
+ // Drop and recreate metadata table
404
+ const tableNames = await this.db.tableNames();
405
+ if (tableNames.includes('file_metadata')) {
406
+ await this.db.dropTable('file_metadata');
407
+ }
408
+ if (metadata.length > 0) {
409
+ this.metadataTable = await this.db.createTable('file_metadata', metadata);
410
+ }
411
+ }
412
+ async getStatus() {
413
+ const tableNames = await this.db?.tableNames();
414
+ const hasTable = tableNames?.includes('code_chunks') ?? false;
415
+ if (!hasTable) {
416
+ return {
417
+ indexed: false,
418
+ fileCount: 0,
419
+ chunkCount: 0,
420
+ lastUpdated: null,
421
+ indexPath: this.indexPath,
422
+ embeddingBackend: this.embeddingBackend.name,
423
+ embeddingModel: this.embeddingBackend.getModel(),
424
+ };
425
+ }
426
+ this.table = await this.db.openTable('code_chunks');
427
+ const count = await this.table.countRows();
428
+ // Load persisted metadata
429
+ const metadata = await this.loadIndexMetadata();
430
+ // Validate index integrity
431
+ const corruptionCheck = await this.validateIndexIntegrity(metadata, count);
432
+ // Check for backend mismatch
433
+ const backendMismatch = this.checkBackendMismatch(metadata);
434
+ return {
435
+ indexed: true,
436
+ fileCount: metadata?.fileCount ?? 0,
437
+ chunkCount: count,
438
+ lastUpdated: metadata?.lastUpdated ?? null,
439
+ indexPath: this.indexPath,
440
+ embeddingBackend: metadata?.embeddingBackend ?? this.embeddingBackend.name,
441
+ embeddingModel: metadata?.embeddingModel ?? this.embeddingBackend.getModel(),
442
+ corrupted: corruptionCheck.corrupted,
443
+ corruptionReason: corruptionCheck.reason,
444
+ backendMismatch: backendMismatch.mismatch,
445
+ backendMismatchReason: backendMismatch.reason,
446
+ chunkingStats: metadata?.chunkingStats,
447
+ };
448
+ }
449
+ /**
450
+ * Check if the current embedding backend differs from the one used to create the index.
451
+ * Returns mismatch status and reason if mismatched.
452
+ */
453
+ checkBackendMismatch(metadata) {
454
+ if (!metadata) {
455
+ return { mismatch: false };
456
+ }
457
+ const currentBackend = this.embeddingBackend.name;
458
+ const currentModel = this.embeddingBackend.getModel();
459
+ const currentDimensions = this.embeddingBackend.getDimensions();
460
+ // Check dimension mismatch (critical - will cause search failures)
461
+ if (metadata.embeddingDimensions && metadata.embeddingDimensions !== currentDimensions) {
462
+ return {
463
+ mismatch: true,
464
+ reason: `Embedding dimension mismatch: index has ${metadata.embeddingDimensions}-dim vectors, ` +
465
+ `current backend (${currentBackend}) produces ${currentDimensions}-dim vectors. Reindex required.`,
466
+ };
467
+ }
468
+ // Check model mismatch (different models produce incompatible embeddings)
469
+ if (metadata.embeddingModel && metadata.embeddingModel !== currentModel) {
470
+ return {
471
+ mismatch: true,
472
+ reason: `Embedding model mismatch: index uses '${metadata.embeddingModel}', ` +
473
+ `current backend uses '${currentModel}'. Reindex required.`,
474
+ };
475
+ }
476
+ // Check backend mismatch (even same dimensions may have different embedding spaces)
477
+ if (metadata.embeddingBackend && metadata.embeddingBackend !== currentBackend) {
478
+ return {
479
+ mismatch: true,
480
+ reason: `Embedding backend changed from '${metadata.embeddingBackend}' to '${currentBackend}'. Reindex required.`,
481
+ };
482
+ }
483
+ return { mismatch: false };
484
+ }
485
+ /**
486
+ * Validate index integrity by checking metadata consistency.
487
+ * Returns corruption status and reason if corrupted.
488
+ */
489
+ async validateIndexIntegrity(metadata, actualChunkCount) {
490
+ // No metadata file - possible incomplete indexing
491
+ if (!metadata) {
492
+ return {
493
+ corrupted: true,
494
+ reason: 'Missing index metadata file. Index may be incomplete. Run clear_index followed by index_codebase to rebuild.',
495
+ };
496
+ }
497
+ // Check if chunk count matches
498
+ if (metadata.chunkCount !== actualChunkCount) {
499
+ return {
500
+ corrupted: true,
501
+ reason: `Chunk count mismatch: metadata says ${metadata.chunkCount}, index has ${actualChunkCount}. Run clear_index followed by index_codebase to rebuild.`,
502
+ };
503
+ }
504
+ // Validate checksum if present
505
+ if (metadata.checksum) {
506
+ const storedFiles = await this.getStoredMetadata();
507
+ const fileList = Array.from(storedFiles.keys());
508
+ const computedChecksum = computeIndexChecksum(fileList, actualChunkCount);
509
+ if (computedChecksum !== metadata.checksum) {
510
+ return {
511
+ corrupted: true,
512
+ reason: `Checksum mismatch: file metadata does not match index. Run clear_index followed by index_codebase to rebuild.`,
513
+ };
514
+ }
515
+ }
516
+ return { corrupted: false };
517
+ }
518
+ async indexCodebase(patterns, excludePatterns, forceReindex = false, onProgress, autoRepair = false) {
519
+ const { glob } = await import('glob');
520
+ const report = (progress) => {
521
+ console.error(`[glancey] ${progress.message}`);
522
+ onProgress?.(progress);
523
+ };
524
+ // Reset chunking stats for this indexing run
525
+ this.currentChunkingStats = this.createEmptyChunkingStats();
526
+ // Check for corruption if autoRepair is enabled
527
+ if (autoRepair) {
528
+ const status = await this.getStatus();
529
+ if (status.corrupted) {
530
+ console.error(`[glancey] Index corruption detected: ${status.corruptionReason}`);
531
+ console.error('[glancey] Auto-repair enabled, clearing and rebuilding index...');
532
+ await this.clearIndex();
533
+ // Recursively call with forceReindex but without autoRepair to avoid loops
534
+ const result = await this.indexCodebase(patterns, excludePatterns, true, onProgress, false);
535
+ return { ...result, repaired: true };
536
+ }
537
+ }
538
+ // Check for incomplete checkpoint (resume interrupted indexing)
539
+ if (!forceReindex) {
540
+ const checkpoint = await this.loadCheckpoint();
541
+ if (checkpoint && checkpoint.phase !== 'complete') {
542
+ // Validate checkpoint is compatible with current backend
543
+ const currentBackend = this.embeddingBackend.name;
544
+ const currentModel = this.embeddingBackend.getModel();
545
+ if (checkpoint.embeddingBackend !== currentBackend ||
546
+ checkpoint.embeddingModel !== currentModel) {
547
+ console.error(`[glancey] Checkpoint uses different embedding backend/model ` +
548
+ `(${checkpoint.embeddingBackend}/${checkpoint.embeddingModel} vs ${currentBackend}/${currentModel}), ` +
549
+ `discarding checkpoint`);
550
+ await this.clearCheckpoint();
551
+ }
552
+ else {
553
+ // Validate checkpoint freshness - ensure files haven't changed
554
+ const isFresh = await this.validateCheckpointFreshness(checkpoint);
555
+ if (!isFresh) {
556
+ console.error(`[glancey] Checkpoint is stale (files modified since ${checkpoint.startedAt}), discarding`);
557
+ await this.clearCheckpoint();
558
+ }
559
+ else {
560
+ console.error(`[glancey] Found incomplete checkpoint from ${checkpoint.startedAt}, resuming...`);
561
+ return this.resumeFromCheckpoint(checkpoint, onProgress);
562
+ }
563
+ }
564
+ }
565
+ }
566
+ // Use provided patterns or fall back to config/defaults
567
+ const effectivePatterns = patterns || this.config?.patterns || getDefaultPatterns();
568
+ const effectiveExcludePatterns = excludePatterns || this.config?.excludePatterns || getDefaultExcludePatterns();
569
+ report({ phase: 'scanning', current: 0, total: 0, message: 'Scanning for files...' });
570
+ // Find all matching files
571
+ const files = [];
572
+ for (const pattern of effectivePatterns) {
573
+ const matches = await glob(pattern, {
574
+ cwd: this.projectPath,
575
+ ignore: effectiveExcludePatterns,
576
+ absolute: true,
577
+ });
578
+ files.push(...matches);
579
+ }
580
+ report({
581
+ phase: 'scanning',
582
+ current: files.length,
583
+ total: files.length,
584
+ message: `Found ${files.length} files to index`,
585
+ });
586
+ // Check if we can do incremental indexing
587
+ const tableNames = await this.db.tableNames();
588
+ const hasExistingIndex = tableNames.includes('code_chunks');
589
+ // Check for embedding dimension or model mismatch
590
+ let embeddingMismatch = false;
591
+ if (hasExistingIndex && !forceReindex) {
592
+ const metadata = await this.loadIndexMetadata();
593
+ const currentDimensions = this.embeddingBackend.getDimensions();
594
+ const currentModel = this.embeddingBackend.getModel();
595
+ // Check dimension mismatch
596
+ if (metadata?.embeddingDimensions && metadata.embeddingDimensions !== currentDimensions) {
597
+ console.error(`[glancey] Embedding dimension mismatch: index has ${metadata.embeddingDimensions}, ` +
598
+ `current backend (${this.embeddingBackend.name}) uses ${currentDimensions}. Forcing full reindex.`);
599
+ embeddingMismatch = true;
600
+ }
601
+ // Check model mismatch (even if dimensions match, different models produce incompatible embeddings)
602
+ if (metadata?.embeddingModel && metadata.embeddingModel !== currentModel) {
603
+ console.error(`[glancey] Embedding model mismatch: index uses '${metadata.embeddingModel}', ` +
604
+ `current backend uses '${currentModel}'. Forcing full reindex.`);
605
+ embeddingMismatch = true;
606
+ }
607
+ }
608
+ const canDoIncremental = hasExistingIndex && !forceReindex && !embeddingMismatch;
609
+ if (canDoIncremental) {
610
+ return this.indexIncremental(files, onProgress);
611
+ }
612
+ // Full reindex
613
+ return this.indexFull(files, onProgress);
614
+ }
615
+ /**
616
+ * Perform a full reindex of all files
617
+ */
618
+ async indexFull(files, onProgress) {
619
+ const report = (progress) => {
620
+ console.error(`[glancey] ${progress.message}`);
621
+ onProgress?.(progress);
622
+ };
623
+ const startedAt = new Date().toISOString();
624
+ // Process files into chunks (parallelized for I/O efficiency)
625
+ report({ phase: 'chunking', current: 0, total: files.length, message: 'Chunking files...' });
626
+ const chunkResults = await mapInBatches(files, async (filepath) => this.chunkFile(filepath), FILE_PROCESSING_CONCURRENCY, (completed, total) => {
627
+ report({
628
+ phase: 'chunking',
629
+ current: completed,
630
+ total,
631
+ message: `Chunked ${completed}/${total} files`,
632
+ });
633
+ });
634
+ const allChunks = chunkResults.flat();
635
+ report({
636
+ phase: 'chunking',
637
+ current: files.length,
638
+ total: files.length,
639
+ message: `Created ${allChunks.length} chunks`,
640
+ });
641
+ // Save checkpoint after chunking (non-blocking - embedding starts immediately)
642
+ // Skip mtime collection - it's slow for large codebases and only used for checkpoint validation
643
+ this.saveCheckpoint({
644
+ phase: 'chunking',
645
+ startedAt,
646
+ files,
647
+ processedFiles: [],
648
+ pendingChunks: allChunks,
649
+ embeddingBackend: this.embeddingBackend.name,
650
+ embeddingModel: this.embeddingBackend.getModel(),
651
+ });
652
+ // Generate embeddings in batches
653
+ await this.embedChunks(allChunks, onProgress);
654
+ // Save checkpoint after embedding (before storage) - blocking to ensure embeddings are saved
655
+ await this.saveCheckpoint({
656
+ phase: 'embedding',
657
+ startedAt,
658
+ files,
659
+ processedFiles: [],
660
+ embeddedChunks: allChunks,
661
+ embeddingBackend: this.embeddingBackend.name,
662
+ embeddingModel: this.embeddingBackend.getModel(),
663
+ }, { blocking: true });
664
+ // Store in LanceDB
665
+ const data = allChunks.map((chunk) => ({
666
+ id: chunk.id,
667
+ filepath: chunk.filepath,
668
+ content: chunk.content,
669
+ startLine: chunk.startLine,
670
+ endLine: chunk.endLine,
671
+ language: chunk.language,
672
+ vector: chunk.embedding,
673
+ }));
674
+ // Drop existing table if exists
675
+ const tableNames = await this.db.tableNames();
676
+ if (tableNames.includes('code_chunks')) {
677
+ await this.db.dropTable('code_chunks');
678
+ }
679
+ this.table = await this.db.createTable('code_chunks', data);
680
+ // Save file metadata for future incremental indexing
681
+ await this.saveFileMetadata(files);
682
+ // Save index metadata with checksum
683
+ await this.saveIndexMetadata(files.length, allChunks.length, files);
684
+ // Log chunking statistics
685
+ this.logChunkingStats();
686
+ // Clear checkpoint on successful completion
687
+ await this.clearCheckpoint();
688
+ return {
689
+ filesIndexed: files.length,
690
+ chunksCreated: allChunks.length,
691
+ incremental: false,
692
+ };
693
+ }
694
+ /**
695
+ * Log chunking statistics summary, with warnings for fallbacks
696
+ */
697
+ logChunkingStats() {
698
+ const stats = this.currentChunkingStats;
699
+ const totalFallbacks = stats.astFallbacks.length + stats.treeSitterFallbacks.length;
700
+ const totalFiles = stats.astChunked + stats.treeSitterChunked + stats.lineBasedChunked;
701
+ console.error(`[glancey] Chunking: ${stats.astChunked} AST, ${stats.treeSitterChunked} tree-sitter, ${stats.lineBasedChunked} line-based`);
702
+ if (totalFallbacks > 0) {
703
+ const fallbackPct = ((totalFallbacks / totalFiles) * 100).toFixed(1);
704
+ console.error(`[glancey] Warning: ${totalFallbacks} files (${fallbackPct}%) fell back to line-based chunking`);
705
+ if (stats.astFallbacks.length > 0 && stats.astFallbacks.length <= 5) {
706
+ console.error(`[glancey] AST fallbacks: ${stats.astFallbacks.join(', ')}`);
707
+ }
708
+ else if (stats.astFallbacks.length > 5) {
709
+ console.error(`[glancey] AST fallbacks: ${stats.astFallbacks.slice(0, 5).join(', ')} (+${stats.astFallbacks.length - 5} more)`);
710
+ }
711
+ if (stats.treeSitterFallbacks.length > 0 && stats.treeSitterFallbacks.length <= 5) {
712
+ console.error(`[glancey] Tree-sitter fallbacks: ${stats.treeSitterFallbacks.join(', ')}`);
713
+ }
714
+ else if (stats.treeSitterFallbacks.length > 5) {
715
+ console.error(`[glancey] Tree-sitter fallbacks: ${stats.treeSitterFallbacks.slice(0, 5).join(', ')} (+${stats.treeSitterFallbacks.length - 5} more)`);
716
+ }
717
+ }
718
+ }
719
+ /**
720
+ * Perform incremental indexing - only process changed files
721
+ */
722
+ async indexIncremental(files, onProgress) {
723
+ const report = (progress) => {
724
+ console.error(`[glancey] ${progress.message}`);
725
+ onProgress?.(progress);
726
+ };
727
+ const changes = await this.detectFileChanges(files);
728
+ const filesToProcess = [...changes.added, ...changes.modified];
729
+ const hasChanges = filesToProcess.length > 0 || changes.deleted.length > 0;
730
+ if (!hasChanges) {
731
+ report({
732
+ phase: 'complete',
733
+ current: 0,
734
+ total: 0,
735
+ message: 'No changes detected, index is up to date',
736
+ });
737
+ this.table = await this.db.openTable('code_chunks');
738
+ const count = await this.table.countRows();
739
+ return {
740
+ filesIndexed: 0,
741
+ chunksCreated: count,
742
+ incremental: true,
743
+ };
744
+ }
745
+ report({
746
+ phase: 'scanning',
747
+ current: 0,
748
+ total: filesToProcess.length,
749
+ message: `Incremental update: ${changes.added.length} added, ${changes.modified.length} modified, ${changes.deleted.length} deleted`,
750
+ });
751
+ // Open the existing table
752
+ this.table = await this.db.openTable('code_chunks');
753
+ // Delete chunks from modified and deleted files
754
+ const filesToRemove = [
755
+ ...changes.modified.map((f) => path.relative(this.projectPath, f)),
756
+ ...changes.deleted,
757
+ ];
758
+ if (filesToRemove.length > 0) {
759
+ for (const relativePath of filesToRemove) {
760
+ const sanitizedPath = sanitizeForFilter(relativePath);
761
+ await this.table.delete(`filepath = '${sanitizedPath}'`);
762
+ }
763
+ report({
764
+ phase: 'chunking',
765
+ current: 0,
766
+ total: filesToProcess.length,
767
+ message: `Removed chunks from ${filesToRemove.length} files`,
768
+ });
769
+ }
770
+ // Process new and modified files (parallelized for I/O efficiency)
771
+ if (filesToProcess.length > 0) {
772
+ const chunkResults = await mapInBatches(filesToProcess, async (filepath) => this.chunkFile(filepath), FILE_PROCESSING_CONCURRENCY, (completed, total) => {
773
+ report({
774
+ phase: 'chunking',
775
+ current: completed,
776
+ total,
777
+ message: `Chunked ${completed}/${total} files`,
778
+ });
779
+ });
780
+ const newChunks = chunkResults.flat();
781
+ report({
782
+ phase: 'chunking',
783
+ current: filesToProcess.length,
784
+ total: filesToProcess.length,
785
+ message: `Created ${newChunks.length} new chunks`,
786
+ });
787
+ // Generate embeddings
788
+ await this.embedChunks(newChunks, onProgress);
789
+ // Add new chunks to the table
790
+ const data = newChunks.map((chunk) => ({
791
+ id: chunk.id,
792
+ filepath: chunk.filepath,
793
+ content: chunk.content,
794
+ startLine: chunk.startLine,
795
+ endLine: chunk.endLine,
796
+ language: chunk.language,
797
+ vector: chunk.embedding,
798
+ }));
799
+ if (data.length > 0) {
800
+ await this.table.add(data);
801
+ }
802
+ }
803
+ // Update file metadata
804
+ const allCurrentFiles = [...changes.unchanged, ...changes.added, ...changes.modified];
805
+ await this.saveFileMetadata(allCurrentFiles);
806
+ const totalChunks = await this.table.countRows();
807
+ // Save index metadata with checksum
808
+ await this.saveIndexMetadata(allCurrentFiles.length, totalChunks, allCurrentFiles);
809
+ // Log chunking statistics
810
+ this.logChunkingStats();
811
+ return {
812
+ filesIndexed: filesToProcess.length,
813
+ chunksCreated: totalChunks,
814
+ incremental: true,
815
+ };
816
+ }
817
+ /**
818
+ * Resume indexing from a saved checkpoint.
819
+ * Handles each checkpoint phase appropriately.
820
+ */
821
+ async resumeFromCheckpoint(checkpoint, onProgress) {
822
+ const report = (progress) => {
823
+ console.error(`[glancey] ${progress.message}`);
824
+ onProgress?.(progress);
825
+ };
826
+ report({
827
+ phase: checkpoint.phase,
828
+ current: 0,
829
+ total: checkpoint.files.length,
830
+ message: `Resuming from ${checkpoint.phase} phase (started ${checkpoint.startedAt})`,
831
+ });
832
+ let allChunks;
833
+ switch (checkpoint.phase) {
834
+ case 'chunking': {
835
+ // Resume from chunking phase - chunks are ready but not embedded
836
+ if (!checkpoint.pendingChunks || checkpoint.pendingChunks.length === 0) {
837
+ console.error('[glancey] Checkpoint has no pending chunks, restarting full index');
838
+ await this.clearCheckpoint();
839
+ return this.indexFull(checkpoint.files, onProgress);
840
+ }
841
+ allChunks = checkpoint.pendingChunks;
842
+ // Re-read chunk content from files (stripped during checkpoint save to reduce size)
843
+ report({
844
+ phase: 'chunking',
845
+ current: 0,
846
+ total: allChunks.length,
847
+ message: `Re-reading content for ${allChunks.length} chunks...`,
848
+ });
849
+ await this.rehydrateChunkContent(allChunks);
850
+ report({
851
+ phase: 'chunking',
852
+ current: checkpoint.files.length,
853
+ total: checkpoint.files.length,
854
+ message: `Resumed with ${allChunks.length} chunks ready for embedding`,
855
+ });
856
+ // Continue with embedding
857
+ await this.embedChunks(allChunks, onProgress);
858
+ // Save checkpoint after embedding (preserve fileMtimes from original checkpoint) - blocking
859
+ await this.saveCheckpoint({
860
+ phase: 'embedding',
861
+ startedAt: checkpoint.startedAt,
862
+ files: checkpoint.files,
863
+ processedFiles: [],
864
+ embeddedChunks: allChunks,
865
+ embeddingBackend: this.embeddingBackend.name,
866
+ embeddingModel: this.embeddingBackend.getModel(),
867
+ fileMtimes: checkpoint.fileMtimes,
868
+ }, { blocking: true });
869
+ break;
870
+ }
871
+ case 'embedding': {
872
+ // Resume from embedding phase - chunks are embedded but not stored
873
+ if (!checkpoint.embeddedChunks || checkpoint.embeddedChunks.length === 0) {
874
+ console.error('[glancey] Checkpoint has no embedded chunks, restarting full index');
875
+ await this.clearCheckpoint();
876
+ return this.indexFull(checkpoint.files, onProgress);
877
+ }
878
+ allChunks = checkpoint.embeddedChunks;
879
+ // Re-read chunk content from files (stripped during checkpoint save to reduce size)
880
+ report({
881
+ phase: 'embedding',
882
+ current: 0,
883
+ total: allChunks.length,
884
+ message: `Re-reading content for ${allChunks.length} chunks...`,
885
+ });
886
+ await this.rehydrateChunkContent(allChunks);
887
+ report({
888
+ phase: 'embedding',
889
+ current: allChunks.length,
890
+ total: allChunks.length,
891
+ message: `Resumed with ${allChunks.length} embedded chunks ready for storage`,
892
+ });
893
+ break;
894
+ }
895
+ default:
896
+ // Unknown phase, restart full index
897
+ console.error(`[glancey] Unknown checkpoint phase: ${checkpoint.phase}, restarting`);
898
+ await this.clearCheckpoint();
899
+ return this.indexFull(checkpoint.files, onProgress);
900
+ }
901
+ // Store in LanceDB
902
+ report({
903
+ phase: 'storing',
904
+ current: 0,
905
+ total: allChunks.length,
906
+ message: 'Storing chunks in database...',
907
+ });
908
+ const data = allChunks.map((chunk) => ({
909
+ id: chunk.id,
910
+ filepath: chunk.filepath,
911
+ content: chunk.content,
912
+ startLine: chunk.startLine,
913
+ endLine: chunk.endLine,
914
+ language: chunk.language,
915
+ vector: chunk.embedding,
916
+ }));
917
+ // Drop existing table if exists
918
+ const tableNames = await this.db.tableNames();
919
+ if (tableNames.includes('code_chunks')) {
920
+ await this.db.dropTable('code_chunks');
921
+ }
922
+ this.table = await this.db.createTable('code_chunks', data);
923
+ // Save file metadata for future incremental indexing
924
+ await this.saveFileMetadata(checkpoint.files);
925
+ // Save index metadata with checksum
926
+ await this.saveIndexMetadata(checkpoint.files.length, allChunks.length, checkpoint.files);
927
+ // Clear checkpoint on successful completion
928
+ await this.clearCheckpoint();
929
+ report({
930
+ phase: 'complete',
931
+ current: allChunks.length,
932
+ total: allChunks.length,
933
+ message: `Resumed indexing complete: ${checkpoint.files.length} files, ${allChunks.length} chunks`,
934
+ });
935
+ return {
936
+ filesIndexed: checkpoint.files.length,
937
+ chunksCreated: allChunks.length,
938
+ incremental: false,
939
+ };
940
+ }
941
+ /**
942
+ * Generate embeddings for chunks in batches.
943
+ * Supports configurable batch size and delay between batches for rate limiting.
944
+ */
945
+ async embedChunks(chunks, onProgress) {
946
+ const report = (progress) => {
947
+ console.error(`[glancey] ${progress.message}`);
948
+ onProgress?.(progress);
949
+ };
950
+ // Get indexing config for rate limiting
951
+ const indexingConfig = this.config
952
+ ? getIndexingConfig(this.config)
953
+ : { batchSize: 200, batchDelayMs: 0 };
954
+ const { batchDelayMs } = indexingConfig;
955
+ // Use batch size of 2000 to balance parallelization with progress visibility
956
+ // The embedding backend will split into smaller batches and process in parallel
957
+ // With Ollama defaults (batchSize=100, concurrency=100), 2000 = 20 batches = 1 parallel round
958
+ const embeddingBatchSize = 2000;
959
+ const startTime = Date.now();
960
+ let processedChunks = 0;
961
+ // Report initial progress so user knows embedding has started
962
+ report({
963
+ phase: 'embedding',
964
+ current: 0,
965
+ total: chunks.length,
966
+ message: `Starting embedding of ${chunks.length} chunks...`,
967
+ });
968
+ for (let i = 0; i < chunks.length; i += embeddingBatchSize) {
969
+ const batch = chunks.slice(i, i + embeddingBatchSize);
970
+ const texts = batch.map((c) => c.content);
971
+ const batchMsg = `Sending ${texts.length} texts to embedding backend (batch ${Math.floor(i / embeddingBatchSize) + 1}/${Math.ceil(chunks.length / embeddingBatchSize)})...`;
972
+ console.error(`[glancey] ${batchMsg}`);
973
+ broadcastLog('info', batchMsg);
974
+ const embeddings = await this.embeddingBackend.embedBatch(texts);
975
+ batch.forEach((chunk, idx) => {
976
+ chunk.embedding = embeddings[idx];
977
+ });
978
+ processedChunks = i + batch.length;
979
+ const elapsedMs = Date.now() - startTime;
980
+ const chunksPerMs = processedChunks / elapsedMs;
981
+ const remainingChunks = chunks.length - processedChunks;
982
+ const etaSeconds = chunksPerMs > 0 ? Math.round(remainingChunks / chunksPerMs / 1000) : undefined;
983
+ report({
984
+ phase: 'embedding',
985
+ current: processedChunks,
986
+ total: chunks.length,
987
+ message: `Embedded ${processedChunks}/${chunks.length} chunks`,
988
+ etaSeconds,
989
+ });
990
+ // Apply rate limiting delay between batches (if configured and not the last batch)
991
+ if (batchDelayMs > 0 && i + embeddingBatchSize < chunks.length) {
992
+ await new Promise((resolve) => setTimeout(resolve, batchDelayMs));
993
+ }
994
+ }
995
+ }
996
+ async chunkFile(filepath) {
997
+ const ext = path.extname(filepath).slice(1);
998
+ const language = this.getLanguage(ext);
999
+ const relativePath = path.relative(this.projectPath, filepath);
1000
+ // Try AST-aware chunking for TypeScript/JavaScript
1001
+ if (ASTChunker.canParse(filepath)) {
1002
+ try {
1003
+ const chunks = await this.chunkFileWithAST(filepath, relativePath, language);
1004
+ this.currentChunkingStats.astChunked++;
1005
+ return chunks;
1006
+ }
1007
+ catch {
1008
+ // Fall back to line-based chunking if AST parsing fails
1009
+ console.error(`[glancey] AST parsing failed for ${relativePath}, falling back to line-based chunking`);
1010
+ this.currentChunkingStats.astFallbacks.push(relativePath);
1011
+ }
1012
+ }
1013
+ // Try tree-sitter chunking for other languages (Python, Go, Rust, Java, Kotlin)
1014
+ if (TreeSitterChunker.canParse(filepath)) {
1015
+ try {
1016
+ const chunks = await this.chunkFileWithTreeSitter(filepath, relativePath, language);
1017
+ this.currentChunkingStats.treeSitterChunked++;
1018
+ return chunks;
1019
+ }
1020
+ catch (error) {
1021
+ // Fall back to line-based chunking if tree-sitter parsing fails
1022
+ console.error(`[glancey] Tree-sitter parsing failed for ${relativePath}, falling back to line-based chunking:`, error);
1023
+ this.currentChunkingStats.treeSitterFallbacks.push(relativePath);
1024
+ }
1025
+ }
1026
+ // Line-based chunking for unsupported languages or as fallback
1027
+ this.currentChunkingStats.lineBasedChunked++;
1028
+ return this.chunkFileByLines(filepath, relativePath, language);
1029
+ }
1030
+ /**
1031
+ * Chunk a file using AST-aware parsing
1032
+ */
1033
+ async chunkFileWithAST(filepath, relativePath, language) {
1034
+ const astChunker = new ASTChunker();
1035
+ const astChunks = await astChunker.chunkFile(filepath);
1036
+ return astChunks.map((chunk) => ({
1037
+ id: `${relativePath}:${chunk.startLine}-${chunk.endLine}${chunk.name ? `:${chunk.name}` : ''}`,
1038
+ filepath: relativePath,
1039
+ content: chunk.content,
1040
+ startLine: chunk.startLine,
1041
+ endLine: chunk.endLine,
1042
+ language,
1043
+ symbolType: chunk.type,
1044
+ symbolName: chunk.name,
1045
+ }));
1046
+ }
1047
+ /**
1048
+ * Chunk a file using tree-sitter AST parsing (Python, Go, Rust, Java, Kotlin)
1049
+ */
1050
+ async chunkFileWithTreeSitter(filepath, relativePath, language) {
1051
+ const treeSitterChunker = new TreeSitterChunker();
1052
+ const treeSitterChunks = await treeSitterChunker.chunkFile(filepath);
1053
+ return treeSitterChunks.map((chunk) => ({
1054
+ id: `${relativePath}:${chunk.startLine}-${chunk.endLine}${chunk.name ? `:${chunk.name}` : ''}`,
1055
+ filepath: relativePath,
1056
+ content: chunk.content,
1057
+ startLine: chunk.startLine,
1058
+ endLine: chunk.endLine,
1059
+ language,
1060
+ symbolType: chunk.type,
1061
+ symbolName: chunk.name,
1062
+ }));
1063
+ }
1064
+ /**
1065
+ * Chunk a file using line-based splitting (fallback)
1066
+ */
1067
+ async chunkFileByLines(filepath, relativePath, language) {
1068
+ const content = await fs.readFile(filepath, 'utf-8');
1069
+ const lines = content.split('\n');
1070
+ const chunkingConfig = getChunkingConfig(this.config);
1071
+ const chunkSize = chunkingConfig.maxLines;
1072
+ const chunkOverlap = chunkingConfig.overlap;
1073
+ const chunks = [];
1074
+ for (let i = 0; i < lines.length; i += chunkSize - chunkOverlap) {
1075
+ const chunkLines = lines.slice(i, i + chunkSize);
1076
+ const chunkContent = chunkLines.join('\n');
1077
+ if (chunkContent.trim().length === 0)
1078
+ continue;
1079
+ chunks.push({
1080
+ id: `${relativePath}:${i + 1}-${i + chunkLines.length}`,
1081
+ filepath: relativePath,
1082
+ content: chunkContent,
1083
+ startLine: i + 1,
1084
+ endLine: i + chunkLines.length,
1085
+ language,
1086
+ });
1087
+ }
1088
+ return chunks;
1089
+ }
1090
+ getLanguage(ext) {
1091
+ const langMap = {
1092
+ ts: 'typescript',
1093
+ tsx: 'typescript',
1094
+ js: 'javascript',
1095
+ jsx: 'javascript',
1096
+ py: 'python',
1097
+ go: 'go',
1098
+ rs: 'rust',
1099
+ java: 'java',
1100
+ rb: 'ruby',
1101
+ php: 'php',
1102
+ c: 'c',
1103
+ cpp: 'cpp',
1104
+ h: 'c',
1105
+ hpp: 'cpp',
1106
+ cs: 'csharp',
1107
+ swift: 'swift',
1108
+ kt: 'kotlin',
1109
+ md: 'markdown',
1110
+ json: 'json',
1111
+ yaml: 'yaml',
1112
+ yml: 'yaml',
1113
+ };
1114
+ return langMap[ext] || ext;
1115
+ }
1116
+ /**
1117
+ * Get query embedding from cache or compute it.
1118
+ * Uses TTLCache for LRU eviction and TTL-based expiration.
1119
+ */
1120
+ async getQueryEmbedding(query) {
1121
+ // Check cache first
1122
+ const cached = this.queryEmbeddingCache.get(query);
1123
+ if (cached) {
1124
+ return cached;
1125
+ }
1126
+ // Compute embedding and cache it
1127
+ const embedding = await this.embeddingBackend.embed(query);
1128
+ this.queryEmbeddingCache.set(query, embedding);
1129
+ return embedding;
1130
+ }
1131
+ /**
1132
+ * Check if a filepath matches a glob pattern.
1133
+ * Supports negation patterns starting with '!'.
1134
+ */
1135
+ matchesPathPattern(filepath, pattern) {
1136
+ // Handle negation pattern
1137
+ if (pattern.startsWith('!')) {
1138
+ return !minimatch(filepath, pattern.slice(1));
1139
+ }
1140
+ return minimatch(filepath, pattern);
1141
+ }
1142
+ async search(queryOrOptions, limit) {
1143
+ // Normalize arguments
1144
+ const options = typeof queryOrOptions === 'string'
1145
+ ? { query: queryOrOptions, limit: limit ?? 10 }
1146
+ : queryOrOptions;
1147
+ const { query, limit: resultLimit = 10, pathPattern, languages } = options;
1148
+ if (!this.table) {
1149
+ const status = await this.getStatus();
1150
+ if (!status.indexed) {
1151
+ throw new Error('Codebase not indexed. Run index_codebase first.');
1152
+ }
1153
+ }
1154
+ // Check for stale index and auto-reindex if configured
1155
+ const searchConfig = getSearchConfig(this.config);
1156
+ if (searchConfig.autoReindex) {
1157
+ const staleCheck = await this.checkIfStale();
1158
+ if (staleCheck.stale) {
1159
+ // Perform incremental reindex (indexCodebase auto-detects changes)
1160
+ await this.indexCodebase();
1161
+ }
1162
+ }
1163
+ const queryEmbedding = await this.getQueryEmbedding(query);
1164
+ // Fetch more results than needed for re-ranking and filtering
1165
+ // If we have filters, fetch even more to account for filtered-out results
1166
+ const hasFilters = pathPattern !== undefined || (languages && languages.length > 0);
1167
+ const fetchMultiplier = hasFilters ? 5 : 3;
1168
+ const fetchLimit = Math.min(resultLimit * fetchMultiplier, hasFilters ? 100 : 50);
1169
+ const results = await this.table.search(queryEmbedding).limit(fetchLimit).toArray();
1170
+ // Apply filters
1171
+ let filteredResults = results;
1172
+ if (pathPattern) {
1173
+ filteredResults = filteredResults.filter((r) => this.matchesPathPattern(r.filepath, pathPattern));
1174
+ }
1175
+ if (languages && languages.length > 0) {
1176
+ const normalizedLanguages = languages.map((l) => l.toLowerCase());
1177
+ filteredResults = filteredResults.filter((r) => normalizedLanguages.includes(r.language.toLowerCase()));
1178
+ }
1179
+ // Hybrid scoring: combine semantic similarity with keyword matching
1180
+ const scoredResults = filteredResults.map((r, index) => {
1181
+ // Semantic score: inverse of rank (higher is better)
1182
+ const semanticScore = 1 - index / fetchLimit;
1183
+ // Keyword score: based on query term matches
1184
+ const keywordScore = this.calculateKeywordScore(query, r.content, r.filepath);
1185
+ // Combined score using configurable weights
1186
+ const combinedScore = searchConfig.semanticWeight * semanticScore + searchConfig.keywordWeight * keywordScore;
1187
+ return { result: r, score: combinedScore };
1188
+ });
1189
+ // Sort by combined score and take top results
1190
+ scoredResults.sort((a, b) => b.score - a.score);
1191
+ return scoredResults.slice(0, resultLimit).map((sr) => ({
1192
+ id: sr.result.id,
1193
+ filepath: sr.result.filepath,
1194
+ content: sr.result.content,
1195
+ startLine: sr.result.startLine,
1196
+ endLine: sr.result.endLine,
1197
+ language: sr.result.language,
1198
+ symbolType: sr.result.symbolType,
1199
+ symbolName: sr.result.symbolName,
1200
+ }));
1201
+ }
1202
+ /**
1203
+ * Calculate keyword match score for hybrid search
1204
+ */
1205
+ calculateKeywordScore(query, content, filepath) {
1206
+ const queryTerms = query
1207
+ .toLowerCase()
1208
+ .split(/\s+/)
1209
+ .filter((t) => t.length > 2);
1210
+ if (queryTerms.length === 0)
1211
+ return 0;
1212
+ const contentLower = content.toLowerCase();
1213
+ const filepathLower = filepath.toLowerCase();
1214
+ let matchCount = 0;
1215
+ let exactMatchBonus = 0;
1216
+ for (const term of queryTerms) {
1217
+ // Check content matches
1218
+ if (contentLower.includes(term)) {
1219
+ matchCount++;
1220
+ // Bonus for exact word match (not just substring)
1221
+ const wordBoundaryRegex = new RegExp(`\\b${term}\\b`, 'i');
1222
+ if (wordBoundaryRegex.test(content)) {
1223
+ exactMatchBonus += 0.5;
1224
+ }
1225
+ }
1226
+ // Bonus for filename/path match
1227
+ if (filepathLower.includes(term)) {
1228
+ matchCount += 0.5;
1229
+ }
1230
+ }
1231
+ // Normalize score to 0-1 range
1232
+ const baseScore = matchCount / queryTerms.length;
1233
+ const bonusScore = Math.min(exactMatchBonus / queryTerms.length, 0.5);
1234
+ return Math.min(baseScore + bonusScore, 1);
1235
+ }
1236
+ /**
1237
+ * Find code chunks semantically similar to a given code snippet or file location.
1238
+ * This is useful for finding duplicate logic, similar implementations, or related code.
1239
+ */
1240
+ async searchSimilar(options) {
1241
+ const { filepath, startLine, endLine, code, limit = 10, threshold = 0, excludeSelf = true, } = options;
1242
+ // Validate input first - need either code or filepath
1243
+ if (!code && !filepath) {
1244
+ throw new Error('Either code or filepath must be provided');
1245
+ }
1246
+ if (!this.table) {
1247
+ const status = await this.getStatus();
1248
+ if (!status.indexed) {
1249
+ throw new Error('Codebase not indexed. Run index_codebase first.');
1250
+ }
1251
+ }
1252
+ // Get the source code to find similar chunks for
1253
+ let sourceCode;
1254
+ let sourceId = null;
1255
+ if (code) {
1256
+ sourceCode = code;
1257
+ }
1258
+ else {
1259
+ // Read from file
1260
+ const fullPath = path.join(this.projectPath, filepath);
1261
+ const fileContent = await fs.readFile(fullPath, 'utf-8');
1262
+ const lines = fileContent.split('\n');
1263
+ const start = startLine ? startLine - 1 : 0;
1264
+ const end = endLine ? endLine : lines.length;
1265
+ sourceCode = lines.slice(start, end).join('\n');
1266
+ // Build source ID for exclusion
1267
+ if (startLine && endLine) {
1268
+ sourceId = `${filepath}:${startLine}-${endLine}`;
1269
+ }
1270
+ }
1271
+ if (!sourceCode.trim()) {
1272
+ throw new Error('Source code is empty');
1273
+ }
1274
+ // Embed the source code
1275
+ const sourceEmbedding = await this.embeddingBackend.embed(sourceCode);
1276
+ // Search for similar chunks - fetch extra to account for filtering
1277
+ const fetchLimit = Math.min((limit + 5) * 2, 100);
1278
+ const results = await this.table.search(sourceEmbedding).limit(fetchLimit).toArray();
1279
+ // LanceDB returns results sorted by distance (ascending)
1280
+ // Convert distance to similarity score (1 - normalized_distance)
1281
+ const maxDistance = results.length > 0 ? Math.max(...results.map((r) => r._distance || 0)) : 1;
1282
+ const scoredResults = [];
1283
+ for (const r of results) {
1284
+ // Skip self if requested
1285
+ if (excludeSelf && sourceId && r.id === sourceId) {
1286
+ continue;
1287
+ }
1288
+ // Also skip if content is identical (for code-based search)
1289
+ if (excludeSelf && code && r.content.trim() === code.trim()) {
1290
+ continue;
1291
+ }
1292
+ // Convert distance to similarity (0 = far, 1 = identical)
1293
+ const distance = r._distance || 0;
1294
+ const similarity = maxDistance > 0 ? 1 - distance / maxDistance : 1;
1295
+ // Apply threshold filter
1296
+ if (similarity < threshold) {
1297
+ continue;
1298
+ }
1299
+ scoredResults.push({
1300
+ id: r.id,
1301
+ filepath: r.filepath,
1302
+ content: r.content,
1303
+ startLine: r.startLine,
1304
+ endLine: r.endLine,
1305
+ language: r.language,
1306
+ similarity,
1307
+ symbolType: r.symbolType,
1308
+ symbolName: r.symbolName,
1309
+ });
1310
+ if (scoredResults.length >= limit) {
1311
+ break;
1312
+ }
1313
+ }
1314
+ return scoredResults;
1315
+ }
1316
+ async clearIndex() {
1317
+ const tableNames = await this.db.tableNames();
1318
+ if (tableNames.includes('code_chunks')) {
1319
+ await this.db.dropTable('code_chunks');
1320
+ }
1321
+ this.table = null;
1322
+ // Clear query embedding cache to prevent stale embeddings
1323
+ this.queryEmbeddingCache.clear();
1324
+ // Clear clustering metadata
1325
+ await this.clearClusteringMetadata();
1326
+ // Clear any incomplete checkpoint
1327
+ await this.clearCheckpoint();
1328
+ }
1329
+ get clusteringMetadataPath() {
1330
+ return path.join(this.indexPath, 'clustering-metadata.json');
1331
+ }
1332
+ /**
1333
+ * Clear clustering metadata file
1334
+ */
1335
+ async clearClusteringMetadata() {
1336
+ try {
1337
+ await fs.unlink(this.clusteringMetadataPath);
1338
+ }
1339
+ catch {
1340
+ // Ignore errors if file doesn't exist
1341
+ }
1342
+ }
1343
+ /**
1344
+ * Save clustering result to metadata file.
1345
+ * Includes index checksum for cache invalidation.
1346
+ */
1347
+ async saveClusteringMetadata(result) {
1348
+ await fs.mkdir(this.indexPath, { recursive: true });
1349
+ // Get current index checksum for cache validation
1350
+ const indexMetadata = await this.loadIndexMetadata();
1351
+ const data = {
1352
+ clusterCount: result.clusterCount,
1353
+ clusters: result.clusters,
1354
+ // Convert Map to object for JSON serialization
1355
+ assignments: Object.fromEntries(result.assignments),
1356
+ generatedAt: new Date().toISOString(),
1357
+ // Store index checksum for cache invalidation
1358
+ indexChecksum: indexMetadata?.checksum ?? null,
1359
+ };
1360
+ await fs.writeFile(this.clusteringMetadataPath, JSON.stringify(data, null, 2));
1361
+ }
1362
+ /**
1363
+ * Load clustering result from metadata file.
1364
+ * Validates that the cached clustering matches the current index checksum.
1365
+ * Returns null if cache is stale or missing.
1366
+ */
1367
+ async loadClusteringMetadata() {
1368
+ try {
1369
+ const content = await fs.readFile(this.clusteringMetadataPath, 'utf-8');
1370
+ const data = JSON.parse(content);
1371
+ // Validate cache against current index checksum
1372
+ if (data.indexChecksum) {
1373
+ const indexMetadata = await this.loadIndexMetadata();
1374
+ if (indexMetadata?.checksum && indexMetadata.checksum !== data.indexChecksum) {
1375
+ // Cache is stale - index has changed since clustering was generated
1376
+ return null;
1377
+ }
1378
+ }
1379
+ return {
1380
+ clusterCount: data.clusterCount,
1381
+ clusters: data.clusters,
1382
+ // Convert object back to Map
1383
+ assignments: new Map(Object.entries(data.assignments).map(([k, v]) => [k, v])),
1384
+ };
1385
+ }
1386
+ catch {
1387
+ return null;
1388
+ }
1389
+ }
1390
+ /**
1391
+ * Cluster the indexed codebase into semantic concept areas.
1392
+ * Uses k-means clustering on embeddings to discover related code groups.
1393
+ */
1394
+ async clusterConcepts(options = {}) {
1395
+ if (!this.table) {
1396
+ const status = await this.getStatus();
1397
+ if (!status.indexed) {
1398
+ throw new Error('Codebase not indexed. Run index_codebase first.');
1399
+ }
1400
+ this.table = await this.db.openTable('code_chunks');
1401
+ }
1402
+ // Fetch all chunks with embeddings
1403
+ const rows = await this.table.query().toArray();
1404
+ const chunks = rows.map((row) => ({
1405
+ id: row.id,
1406
+ content: row.content,
1407
+ filepath: row.filepath,
1408
+ embedding: row.vector,
1409
+ symbolName: row.symbolName,
1410
+ symbolType: row.symbolType,
1411
+ }));
1412
+ // Perform clustering
1413
+ const result = kMeansClustering(chunks, options);
1414
+ // Save to metadata file
1415
+ await this.saveClusteringMetadata(result);
1416
+ return result;
1417
+ }
1418
+ /**
1419
+ * List all discovered concept clusters.
1420
+ * Returns cached clustering result if available, otherwise clusters first.
1421
+ */
1422
+ async listConcepts(forceRecluster = false) {
1423
+ if (!forceRecluster) {
1424
+ const cached = await this.loadClusteringMetadata();
1425
+ if (cached) {
1426
+ return cached.clusters;
1427
+ }
1428
+ }
1429
+ const result = await this.clusterConcepts();
1430
+ return result.clusters;
1431
+ }
1432
+ /**
1433
+ * Search for code within a specific concept cluster.
1434
+ * Returns chunks that belong to the specified cluster, optionally filtered by query.
1435
+ */
1436
+ async searchByConcept(conceptId, query, limit = 10) {
1437
+ const clustering = await this.loadClusteringMetadata();
1438
+ if (!clustering) {
1439
+ throw new Error('No clustering data available. Run clusterConcepts first.');
1440
+ }
1441
+ // Get chunk IDs in this cluster
1442
+ const chunkIds = new Set();
1443
+ for (const [chunkId, clusterId] of clustering.assignments) {
1444
+ if (clusterId === conceptId) {
1445
+ chunkIds.add(chunkId);
1446
+ }
1447
+ }
1448
+ if (chunkIds.size === 0) {
1449
+ return [];
1450
+ }
1451
+ if (!this.table) {
1452
+ this.table = await this.db.openTable('code_chunks');
1453
+ }
1454
+ // If query provided, use semantic search and filter to cluster
1455
+ if (query) {
1456
+ const queryEmbedding = await this.getQueryEmbedding(query);
1457
+ const results = await this.table
1458
+ .search(queryEmbedding)
1459
+ .limit(limit * 3)
1460
+ .toArray();
1461
+ return results
1462
+ .filter((r) => chunkIds.has(r.id))
1463
+ .slice(0, limit)
1464
+ .map((r) => ({
1465
+ id: r.id,
1466
+ filepath: r.filepath,
1467
+ content: r.content,
1468
+ startLine: r.startLine,
1469
+ endLine: r.endLine,
1470
+ language: r.language,
1471
+ symbolType: r.symbolType,
1472
+ symbolName: r.symbolName,
1473
+ }));
1474
+ }
1475
+ // Without query, return representative chunks from the cluster
1476
+ const cluster = clustering.clusters.find((c) => c.id === conceptId);
1477
+ if (!cluster) {
1478
+ return [];
1479
+ }
1480
+ const results = [];
1481
+ for (const chunkId of cluster.representativeChunks.slice(0, limit)) {
1482
+ // Fetch chunk by ID - LanceDB doesn't have direct ID lookup, so we filter
1483
+ const rows = await this.table
1484
+ .query()
1485
+ .where(`id = '${sanitizeForFilter(chunkId)}'`)
1486
+ .limit(1)
1487
+ .toArray();
1488
+ if (rows.length > 0) {
1489
+ const r = rows[0];
1490
+ results.push({
1491
+ id: r.id,
1492
+ filepath: r.filepath,
1493
+ content: r.content,
1494
+ startLine: r.startLine,
1495
+ endLine: r.endLine,
1496
+ language: r.language,
1497
+ symbolType: r.symbolType,
1498
+ symbolName: r.symbolName,
1499
+ });
1500
+ }
1501
+ }
1502
+ return results;
1503
+ }
1504
+ /**
1505
+ * Generate a comprehensive summary of the codebase including concept areas.
1506
+ */
1507
+ async summarizeCodebase(clusteringOptions) {
1508
+ const status = await this.getStatus();
1509
+ if (!status.indexed) {
1510
+ throw new Error('Codebase not indexed. Run index_codebase first.');
1511
+ }
1512
+ if (!this.table) {
1513
+ this.table = await this.db.openTable('code_chunks');
1514
+ }
1515
+ // Gather language statistics
1516
+ const rows = await this.table.query().toArray();
1517
+ const languageStats = new Map();
1518
+ for (const row of rows) {
1519
+ const lang = row.language;
1520
+ if (!languageStats.has(lang)) {
1521
+ languageStats.set(lang, { fileCount: new Set(), chunkCount: 0 });
1522
+ }
1523
+ const stats = languageStats.get(lang);
1524
+ stats.fileCount.add(row.filepath);
1525
+ stats.chunkCount++;
1526
+ }
1527
+ const languages = Array.from(languageStats.entries())
1528
+ .map(([language, stats]) => ({
1529
+ language,
1530
+ fileCount: stats.fileCount.size,
1531
+ chunkCount: stats.chunkCount,
1532
+ }))
1533
+ .sort((a, b) => b.chunkCount - a.chunkCount);
1534
+ // Perform clustering
1535
+ const chunks = rows.map((row) => ({
1536
+ id: row.id,
1537
+ content: row.content,
1538
+ filepath: row.filepath,
1539
+ embedding: row.vector,
1540
+ symbolName: row.symbolName,
1541
+ symbolType: row.symbolType,
1542
+ }));
1543
+ const clusteringResult = kMeansClustering(chunks, clusteringOptions);
1544
+ await this.saveClusteringMetadata(clusteringResult);
1545
+ // Calculate clustering quality
1546
+ const silhouetteScore = calculateSilhouetteScore(chunks, clusteringResult.assignments, clusteringResult.clusters);
1547
+ return {
1548
+ totalFiles: status.fileCount,
1549
+ totalChunks: status.chunkCount,
1550
+ languages,
1551
+ concepts: clusteringResult.clusters,
1552
+ clusteringQuality: silhouetteScore,
1553
+ generatedAt: new Date().toISOString(),
1554
+ };
1555
+ }
1556
+ }
1557
+ //# sourceMappingURL=indexer.js.map