qmd.ts 72 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996
  1. #!/usr/bin/env bun
  2. import { Database } from "bun:sqlite";
  3. import { Glob, $ } from "bun";
  4. import { parseArgs } from "util";
  5. import * as sqliteVec from "sqlite-vec";
  6. import {
  7. getDb,
  8. getDbPath,
  9. getPwd,
  10. getRealPath,
  11. homedir,
  12. resolve,
  13. setCustomIndexName,
  14. searchFTS,
  15. searchVec,
  16. reciprocalRankFusion,
  17. extractSnippet,
  18. getContextForFile,
  19. getCollectionIdByName,
  20. findSimilarFiles,
  21. matchFilesByGlob,
  22. getHashesNeedingEmbedding,
  23. getDocument as storeGetDocument,
  24. getMultipleDocuments as storeMultiGetDocuments,
  25. getStatus,
  26. hashContent,
  27. extractTitle,
  28. formatDocForEmbedding,
  29. formatQueryForEmbedding,
  30. chunkDocument,
  31. ensureVecTable,
  32. clearCache,
  33. getCacheKey,
  34. getCachedResult,
  35. setCachedResult,
  36. getIndexHealth,
  37. OLLAMA_URL,
  38. DEFAULT_EMBED_MODEL,
  39. DEFAULT_QUERY_MODEL,
  40. DEFAULT_RERANK_MODEL,
  41. DEFAULT_GLOB,
  42. DEFAULT_MULTI_GET_MAX_BYTES,
  43. } from "./store.js";
  44. import type { SearchResult, RankedResult } from "./store.js";
  45. import {
  46. formatSearchResults,
  47. formatDocuments,
  48. escapeXml,
  49. escapeCSV,
  50. type OutputFormat,
  51. } from "./formatter.js";
  52. // Chunking: ~2000 tokens per chunk, ~3 bytes/token = 6KB
  53. const CHUNK_BYTE_SIZE = 6 * 1024;
  54. // Terminal colors (respects NO_COLOR env)
  55. const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
  56. const c = {
  57. reset: useColor ? "\x1b[0m" : "",
  58. dim: useColor ? "\x1b[2m" : "",
  59. bold: useColor ? "\x1b[1m" : "",
  60. cyan: useColor ? "\x1b[36m" : "",
  61. yellow: useColor ? "\x1b[33m" : "",
  62. green: useColor ? "\x1b[32m" : "",
  63. magenta: useColor ? "\x1b[35m" : "",
  64. blue: useColor ? "\x1b[34m" : "",
  65. };
  66. // Terminal cursor control
  67. const cursor = {
  68. hide() { process.stderr.write('\x1b[?25l'); },
  69. show() { process.stderr.write('\x1b[?25h'); },
  70. };
  71. // Ensure cursor is restored on exit
  72. process.on('SIGINT', () => { cursor.show(); process.exit(130); });
  73. process.on('SIGTERM', () => { cursor.show(); process.exit(143); });
  74. // Terminal progress bar using OSC 9;4 escape sequence
  75. const progress = {
  76. set(percent: number) {
  77. process.stderr.write(`\x1b]9;4;1;${Math.round(percent)}\x07`);
  78. },
  79. clear() {
  80. process.stderr.write(`\x1b]9;4;0\x07`);
  81. },
  82. indeterminate() {
  83. process.stderr.write(`\x1b]9;4;3\x07`);
  84. },
  85. error() {
  86. process.stderr.write(`\x1b]9;4;2\x07`);
  87. },
  88. };
  89. // Format seconds into human-readable ETA
  90. function formatETA(seconds: number): string {
  91. if (seconds < 60) return `${Math.round(seconds)}s`;
  92. if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
  93. return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
  94. }
  95. // Check index health and print warnings/tips
  96. function checkIndexHealth(db: Database): void {
  97. const { needsEmbedding, totalDocs, daysStale } = getIndexHealth(db);
  98. // Warn if many docs need embedding
  99. if (needsEmbedding > 0) {
  100. const pct = Math.round((needsEmbedding / totalDocs) * 100);
  101. if (pct >= 10) {
  102. process.stderr.write(`${c.yellow}Warning: ${needsEmbedding} documents (${pct}%) need embeddings. Run 'qmd embed' for better results.${c.reset}\n`);
  103. } else {
  104. process.stderr.write(`${c.dim}Tip: ${needsEmbedding} documents need embeddings. Run 'qmd embed' to index them.${c.reset}\n`);
  105. }
  106. }
  107. // Check if most recent document update is older than 2 weeks
  108. if (daysStale !== null && daysStale >= 14) {
  109. process.stderr.write(`${c.dim}Tip: Index last updated ${daysStale} days ago. Run 'qmd update-all' to refresh.${c.reset}\n`);
  110. }
  111. }
  112. // Compute unique display path for a document
  113. // Always include at least parent folder + filename, add more parent dirs until unique
  114. function computeDisplayPath(
  115. filepath: string,
  116. collectionPath: string,
  117. existingPaths: Set<string>
  118. ): string {
  119. // Get path relative to collection (include collection dir name)
  120. const collectionDir = collectionPath.replace(/\/$/, '');
  121. const collectionName = collectionDir.split('/').pop() || '';
  122. let relativePath: string;
  123. if (filepath.startsWith(collectionDir + '/')) {
  124. // filepath is under collection: use collection name + relative path
  125. relativePath = collectionName + filepath.slice(collectionDir.length);
  126. } else {
  127. // Fallback: just use the filepath
  128. relativePath = filepath;
  129. }
  130. const parts = relativePath.split('/').filter(p => p.length > 0);
  131. // Always include at least parent folder + filename (minimum 2 parts if available)
  132. // Then add more parent dirs until unique
  133. const minParts = Math.min(2, parts.length);
  134. for (let i = parts.length - minParts; i >= 0; i--) {
  135. const candidate = parts.slice(i).join('/');
  136. if (!existingPaths.has(candidate)) {
  137. return candidate;
  138. }
  139. }
  140. // Absolute fallback: use full path (should be unique)
  141. return filepath;
  142. }
  143. // Auto-pull model if not found
  144. async function ensureModelAvailable(model: string): Promise<void> {
  145. try {
  146. const response = await fetch(`${OLLAMA_URL}/api/show`, {
  147. method: "POST",
  148. headers: { "Content-Type": "application/json" },
  149. body: JSON.stringify({ name: model }),
  150. });
  151. if (response.ok) return;
  152. } catch {
  153. // Continue to pull attempt
  154. }
  155. console.log(`Model ${model} not found. Pulling...`);
  156. progress.indeterminate();
  157. const pullResponse = await fetch(`${OLLAMA_URL}/api/pull`, {
  158. method: "POST",
  159. headers: { "Content-Type": "application/json" },
  160. body: JSON.stringify({ name: model, stream: false }),
  161. });
  162. if (!pullResponse.ok) {
  163. progress.error();
  164. throw new Error(`Failed to pull model ${model}: ${pullResponse.status} - ${await pullResponse.text()}`);
  165. }
  166. progress.clear();
  167. console.log(`Model ${model} pulled successfully.`);
  168. }
  169. async function getEmbedding(text: string, model: string, isQuery: boolean = false, title?: string, retried: boolean = false): Promise<number[]> {
  170. const input = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text, title);
  171. const response = await fetch(`${OLLAMA_URL}/api/embed`, {
  172. method: "POST",
  173. headers: { "Content-Type": "application/json" },
  174. body: JSON.stringify({ model, input }),
  175. });
  176. if (!response.ok) {
  177. const errorText = await response.text();
  178. if (!retried && (errorText.includes("not found") || errorText.includes("does not exist"))) {
  179. await ensureModelAvailable(model);
  180. return getEmbedding(text, model, isQuery, title, true);
  181. }
  182. throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
  183. }
  184. const data = await response.json() as { embeddings: number[][] };
  185. return data.embeddings[0];
  186. }
  187. // Qwen3-Reranker prompt format (trained for yes/no relevance classification)
  188. const RERANK_SYSTEM = `Judge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be "yes" or "no".`;
  189. function formatRerankPrompt(query: string, title: string, doc: string): string {
  190. return `<Instruct>: Determine if this document from a Shopify knowledge base is relevant to the search query. The query may reference specific Shopify programs, competitions, features, or named concepts (e.g., "Build a Business" competition, "Shop Pay", "Polaris"). Match documents that discuss the queried topic, even if phrasing differs.
  191. <Query>: ${query}
  192. <Document Title>: ${title}
  193. <Document>: ${doc}`;
  194. }
  195. type LogProb = { token: string; logprob: number };
  196. type RerankResponse = {
  197. response: string;
  198. logprobs?: LogProb[];
  199. };
  200. function parseRerankResponse(data: RerankResponse): number {
  201. if (!data.logprobs || data.logprobs.length === 0) {
  202. throw new Error("Reranker response missing logprobs");
  203. }
  204. const firstToken = data.logprobs[0];
  205. const token = firstToken.token.toLowerCase().trim();
  206. const confidence = Math.exp(firstToken.logprob);
  207. if (token === "yes") {
  208. return confidence;
  209. }
  210. if (token === "no") {
  211. return (1 - confidence) * 0.3;
  212. }
  213. throw new Error(`Unexpected reranker token: "${token}"`);
  214. }
  215. async function rerankSingle(prompt: string, model: string, db?: Database, retried: boolean = false): Promise<number> {
  216. // Use generate with raw template for qwen3-reranker format
  217. // Include empty <think> tags as per HuggingFace reference implementation
  218. const fullPrompt = `<|im_start|>system
  219. ${RERANK_SYSTEM}<|im_end|>
  220. <|im_start|>user
  221. ${prompt}<|im_end|>
  222. <|im_start|>assistant
  223. <think>
  224. </think>
  225. `;
  226. const requestBody = {
  227. model,
  228. prompt: fullPrompt,
  229. raw: true,
  230. stream: false,
  231. logprobs: true,
  232. options: { num_predict: 1 },
  233. };
  234. // Check cache
  235. const cacheKey = db ? getCacheKey(`${OLLAMA_URL}/api/generate`, requestBody) : "";
  236. if (db) {
  237. const cached = getCachedResult(db, cacheKey);
  238. if (cached) {
  239. const data = JSON.parse(cached) as RerankResponse;
  240. return parseRerankResponse(data);
  241. }
  242. }
  243. const response = await fetch(`${OLLAMA_URL}/api/generate`, {
  244. method: "POST",
  245. headers: { "Content-Type": "application/json" },
  246. body: JSON.stringify(requestBody),
  247. });
  248. if (!response.ok) {
  249. const errorText = await response.text();
  250. if (!retried && (errorText.includes("not found") || errorText.includes("does not exist"))) {
  251. await ensureModelAvailable(model);
  252. return rerankSingle(prompt, model, db, true);
  253. }
  254. throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
  255. }
  256. const data = await response.json() as RerankResponse;
  257. // Cache the result
  258. if (db) {
  259. setCachedResult(db, cacheKey, JSON.stringify(data));
  260. }
  261. return parseRerankResponse(data);
  262. }
  263. async function rerank(query: string, documents: { file: string; text: string }[], model: string = DEFAULT_RERANK_MODEL, db?: Database): Promise<{ file: string; score: number }[]> {
  264. const results: { file: string; score: number }[] = [];
  265. const total = documents.length;
  266. const PARALLEL = 5;
  267. process.stderr.write(`Reranking ${total} documents with ${model} (parallel: ${PARALLEL})...\n`);
  268. progress.indeterminate();
  269. // Process in parallel batches
  270. for (let i = 0; i < documents.length; i += PARALLEL) {
  271. const batch = documents.slice(i, i + PARALLEL);
  272. const batchResults = await Promise.all(
  273. batch.map(async (doc) => {
  274. try {
  275. // Extract title from filename for reranker context
  276. const title = doc.file.split('/').pop()?.replace(/\.md$/, '') || doc.file;
  277. const prompt = formatRerankPrompt(query, title, doc.text.slice(0, 4000));
  278. const score = await rerankSingle(prompt, model, db);
  279. return { file: doc.file, score };
  280. } catch (err) {
  281. return { file: doc.file, score: 0 };
  282. }
  283. })
  284. );
  285. results.push(...batchResults);
  286. const processed = Math.min(i + PARALLEL, total);
  287. progress.set((processed / total) * 100);
  288. process.stderr.write(`\rReranking: ${processed}/${total}`);
  289. }
  290. progress.clear();
  291. process.stderr.write("\n");
  292. return results.sort((a, b) => b.score - a.score);
  293. }
  294. function getOrCreateCollection(db: Database, pwd: string, globPattern: string): number {
  295. const now = new Date().toISOString();
  296. // Use INSERT OR IGNORE to handle race conditions, then SELECT
  297. db.prepare(`INSERT OR IGNORE INTO collections (pwd, glob_pattern, created_at) VALUES (?, ?, ?)`).run(pwd, globPattern, now);
  298. const existing = db.prepare(`SELECT id FROM collections WHERE pwd = ? AND glob_pattern = ?`).get(pwd, globPattern) as { id: number };
  299. return existing.id;
  300. }
  301. function cleanupDuplicateCollections(db: Database): void {
  302. // Remove duplicate collections keeping the oldest one
  303. db.exec(`
  304. DELETE FROM collections WHERE id NOT IN (
  305. SELECT MIN(id) FROM collections GROUP BY pwd, glob_pattern
  306. )
  307. `);
  308. // Remove bogus "." glob pattern entries (from earlier bug)
  309. db.exec(`DELETE FROM collections WHERE glob_pattern = '.'`);
  310. }
  311. function formatTimeAgo(date: Date): string {
  312. const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
  313. if (seconds < 60) return `${seconds}s ago`;
  314. const minutes = Math.floor(seconds / 60);
  315. if (minutes < 60) return `${minutes}m ago`;
  316. const hours = Math.floor(minutes / 60);
  317. if (hours < 24) return `${hours}h ago`;
  318. const days = Math.floor(hours / 24);
  319. return `${days}d ago`;
  320. }
  321. function formatBytes(bytes: number): string {
  322. if (bytes < 1024) return `${bytes} B`;
  323. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  324. if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  325. return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
  326. }
  327. function showStatus(): void {
  328. const dbPath = getDbPath();
  329. const db = getDb();
  330. // Cleanup any duplicate collections
  331. cleanupDuplicateCollections(db);
  332. // Index size
  333. let indexSize = 0;
  334. try {
  335. const stat = Bun.file(dbPath).size;
  336. indexSize = stat;
  337. } catch {}
  338. // Collections info
  339. const collections = db.prepare(`
  340. SELECT c.id, c.pwd, c.glob_pattern, c.created_at,
  341. COUNT(d.id) as doc_count,
  342. SUM(CASE WHEN d.active = 1 THEN 1 ELSE 0 END) as active_count,
  343. MAX(d.modified_at) as last_modified
  344. FROM collections c
  345. LEFT JOIN documents d ON d.collection_id = c.id
  346. GROUP BY c.id
  347. ORDER BY c.created_at DESC
  348. `).all() as { id: number; pwd: string; glob_pattern: string; created_at: string; doc_count: number; active_count: number; last_modified: string | null }[];
  349. // Overall stats
  350. const totalDocs = db.prepare(`SELECT COUNT(*) as count FROM documents WHERE active = 1`).get() as { count: number };
  351. const vectorCount = db.prepare(`SELECT COUNT(*) as count FROM content_vectors`).get() as { count: number };
  352. const needsEmbedding = getHashesNeedingEmbedding(db);
  353. // Most recent update across all collections
  354. const mostRecent = db.prepare(`SELECT MAX(modified_at) as latest FROM documents WHERE active = 1`).get() as { latest: string | null };
  355. console.log(`${c.bold}QMD Status${c.reset}\n`);
  356. console.log(`Index: ${dbPath}`);
  357. console.log(`Size: ${formatBytes(indexSize)}\n`);
  358. console.log(`${c.bold}Documents${c.reset}`);
  359. console.log(` Total: ${totalDocs.count} files indexed`);
  360. console.log(` Vectors: ${vectorCount.count} embedded`);
  361. if (needsEmbedding > 0) {
  362. console.log(` ${c.yellow}Pending: ${needsEmbedding} need embedding${c.reset} (run 'qmd embed')`);
  363. }
  364. if (mostRecent.latest) {
  365. const lastUpdate = new Date(mostRecent.latest);
  366. console.log(` Updated: ${formatTimeAgo(lastUpdate)}`);
  367. }
  368. // Get all path contexts
  369. const pathContexts = db.prepare(`SELECT path_prefix, context FROM path_contexts ORDER BY path_prefix`).all() as { path_prefix: string; context: string }[];
  370. if (collections.length > 0) {
  371. console.log(`\n${c.bold}Collections${c.reset}`);
  372. for (const col of collections) {
  373. const lastMod = col.last_modified ? formatTimeAgo(new Date(col.last_modified)) : "never";
  374. console.log(` ${c.cyan}${col.pwd}${c.reset}`);
  375. console.log(` ${col.glob_pattern} → ${col.active_count} docs (updated ${lastMod})`);
  376. // Show contexts that match this collection's path
  377. const matchingContexts = pathContexts.filter(ctx =>
  378. ctx.path_prefix.startsWith(col.pwd) || col.pwd.startsWith(ctx.path_prefix)
  379. );
  380. for (const ctx of matchingContexts) {
  381. const displayPath = shortPath(ctx.path_prefix);
  382. console.log(` ${c.dim}context: ${displayPath} → "${ctx.context}"${c.reset}`);
  383. }
  384. }
  385. } else {
  386. console.log(`\n${c.dim}No collections. Run 'qmd add .' to index markdown files.${c.reset}`);
  387. }
  388. db.close();
  389. }
  390. // Update display_paths for all documents that have empty display_path
  391. function updateDisplayPaths(db: Database): number {
  392. // Get all docs with empty display_path, grouped by collection
  393. const emptyDocs = db.prepare(`
  394. SELECT d.id, d.filepath, c.pwd
  395. FROM documents d
  396. JOIN collections c ON d.collection_id = c.id
  397. WHERE d.active = 1 AND (d.display_path IS NULL OR d.display_path = '')
  398. `).all() as { id: number; filepath: string; pwd: string }[];
  399. if (emptyDocs.length === 0) return 0;
  400. // Collect existing display_paths
  401. const existingPaths = new Set<string>(
  402. (db.prepare(`SELECT display_path FROM documents WHERE active = 1 AND display_path != ''`).all() as { display_path: string }[])
  403. .map(r => r.display_path)
  404. );
  405. const updateStmt = db.prepare(`UPDATE documents SET display_path = ? WHERE id = ?`);
  406. let updated = 0;
  407. for (const doc of emptyDocs) {
  408. const displayPath = computeDisplayPath(doc.filepath, doc.pwd, existingPaths);
  409. updateStmt.run(displayPath, doc.id);
  410. existingPaths.add(displayPath);
  411. updated++;
  412. }
  413. return updated;
  414. }
  415. async function updateAllCollections(): Promise<void> {
  416. const db = getDb();
  417. cleanupDuplicateCollections(db);
  418. // Clear Ollama cache on update
  419. clearCache(db);
  420. const collections = db.prepare(`SELECT id, pwd, glob_pattern FROM collections`).all() as { id: number; pwd: string; glob_pattern: string }[];
  421. if (collections.length === 0) {
  422. console.log(`${c.dim}No collections found. Run 'qmd add .' to index markdown files.${c.reset}`);
  423. db.close();
  424. return;
  425. }
  426. // Update display_paths for any documents missing them (migration)
  427. const pathsUpdated = updateDisplayPaths(db);
  428. if (pathsUpdated > 0) {
  429. console.log(`${c.green}✓${c.reset} Updated ${pathsUpdated} display paths`);
  430. }
  431. db.close();
  432. console.log(`${c.bold}Updating ${collections.length} collection(s)...${c.reset}\n`);
  433. for (let i = 0; i < collections.length; i++) {
  434. const col = collections[i];
  435. console.log(`${c.cyan}[${i + 1}/${collections.length}]${c.reset} ${c.bold}${col.pwd}${c.reset}`);
  436. console.log(`${c.dim} Pattern: ${col.glob_pattern}${c.reset}`);
  437. // Temporarily set PWD for indexing
  438. const originalPwd = process.env.PWD;
  439. process.env.PWD = col.pwd;
  440. await indexFiles(col.glob_pattern);
  441. process.env.PWD = originalPwd;
  442. console.log("");
  443. }
  444. console.log(`${c.green}✓ All collections updated.${c.reset}`);
  445. }
  446. async function addContext(pathArg: string, contextText: string): Promise<void> {
  447. const db = getDb();
  448. const now = new Date().toISOString();
  449. // Resolve path - could be relative, absolute, or use ~
  450. let pathPrefix = pathArg;
  451. if (pathPrefix === '.' || pathPrefix === './') {
  452. pathPrefix = getPwd();
  453. } else if (pathPrefix.startsWith('~/')) {
  454. pathPrefix = homedir() + pathPrefix.slice(1);
  455. } else if (!pathPrefix.startsWith('/')) {
  456. pathPrefix = resolve(getPwd(), pathPrefix);
  457. }
  458. // Get realpath and normalize: remove trailing slash
  459. pathPrefix = getRealPath(pathPrefix).replace(/\/$/, '');
  460. // Insert or update
  461. db.prepare(`INSERT INTO path_contexts (path_prefix, context, created_at) VALUES (?, ?, ?)
  462. ON CONFLICT(path_prefix) DO UPDATE SET context = excluded.context`).run(pathPrefix, contextText, now);
  463. console.log(`${c.green}✓${c.reset} Added context for: ${shortPath(pathPrefix)}`);
  464. console.log(`${c.dim}Context: ${contextText}${c.reset}`);
  465. db.close();
  466. }
  467. function getDocument(filename: string, fromLine?: number, maxLines?: number): void {
  468. const db = getDb();
  469. // Parse :linenum suffix from filename (e.g., "file.md:100")
  470. let filepath = filename;
  471. const colonMatch = filepath.match(/:(\d+)$/);
  472. if (colonMatch && !fromLine) {
  473. fromLine = parseInt(colonMatch[1], 10);
  474. filepath = filepath.slice(0, -colonMatch[0].length);
  475. }
  476. // Expand ~ to home directory
  477. if (filepath.startsWith('~/')) {
  478. filepath = homedir() + filepath.slice(1);
  479. }
  480. // Try exact match on filepath first
  481. let doc = db.prepare(`SELECT filepath, body FROM documents WHERE filepath = ? AND active = 1`).get(filepath) as { filepath: string; body: string } | null;
  482. // Try exact match on display_path
  483. if (!doc) {
  484. doc = db.prepare(`SELECT filepath, body FROM documents WHERE display_path = ? AND active = 1`).get(filepath) as { filepath: string; body: string } | null;
  485. }
  486. // Try matching by filename ending (allows partial paths)
  487. if (!doc) {
  488. doc = db.prepare(`SELECT filepath, body FROM documents WHERE filepath LIKE ? AND active = 1 LIMIT 1`).get(`%${filepath}`) as { filepath: string; body: string } | null;
  489. }
  490. // Try matching by display_path ending
  491. if (!doc) {
  492. doc = db.prepare(`SELECT filepath, body FROM documents WHERE display_path LIKE ? AND active = 1 LIMIT 1`).get(`%${filepath}`) as { filepath: string; body: string } | null;
  493. }
  494. if (!doc) {
  495. // Suggest similar files using Levenshtein distance
  496. const similar = findSimilarFiles(db, filepath, 5, 5);
  497. console.error(`Document not found: ${filename}`);
  498. if (similar.length > 0) {
  499. console.error(`\nDid you mean one of these?`);
  500. for (const s of similar) {
  501. console.error(` ${s}`);
  502. }
  503. }
  504. db.close();
  505. process.exit(1);
  506. }
  507. // Get context for this file
  508. const context = getContextForFile(db, doc.filepath);
  509. let output = doc.body;
  510. // Apply line filtering if specified
  511. if (fromLine !== undefined || maxLines !== undefined) {
  512. const lines = output.split('\n');
  513. const start = (fromLine || 1) - 1; // Convert to 0-indexed
  514. const end = maxLines !== undefined ? start + maxLines : lines.length;
  515. output = lines.slice(start, end).join('\n');
  516. }
  517. // Output context header if exists
  518. if (context) {
  519. console.log(`Folder Context: ${context}\n---\n`);
  520. }
  521. console.log(output);
  522. db.close();
  523. }
  524. // Multi-get: fetch multiple documents by glob pattern or comma-separated list
  525. function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT_MULTI_GET_MAX_BYTES, format: OutputFormat = "cli"): void {
  526. const db = getDb();
  527. // Check if it's a comma-separated list or a glob pattern
  528. const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?');
  529. let files: { filepath: string; displayPath: string; bodyLength: number }[];
  530. if (isCommaSeparated) {
  531. // Comma-separated list of files
  532. const names = pattern.split(',').map(s => s.trim()).filter(Boolean);
  533. files = [];
  534. for (const name of names) {
  535. // Try exact match on display_path first
  536. let doc = db.prepare(`SELECT filepath, display_path, LENGTH(body) as body_length FROM documents WHERE display_path = ? AND active = 1`).get(name) as { filepath: string; display_path: string; body_length: number } | null;
  537. // Try suffix match
  538. if (!doc) {
  539. doc = db.prepare(`SELECT filepath, display_path, LENGTH(body) as body_length FROM documents WHERE display_path LIKE ? AND active = 1 LIMIT 1`).get(`%${name}`) as { filepath: string; display_path: string; body_length: number } | null;
  540. }
  541. if (doc) {
  542. files.push({ filepath: doc.filepath, displayPath: doc.display_path, bodyLength: doc.body_length });
  543. } else {
  544. // Suggest similar files
  545. const similar = findSimilarFiles(db, name, 5, 3);
  546. console.error(`File not found: ${name}`);
  547. if (similar.length > 0) {
  548. console.error(` Did you mean: ${similar.join(', ')}`);
  549. }
  550. }
  551. }
  552. } else {
  553. // Glob pattern on display_path
  554. files = matchFilesByGlob(db, pattern);
  555. if (files.length === 0) {
  556. console.error(`No files matched pattern: ${pattern}`);
  557. db.close();
  558. process.exit(1);
  559. }
  560. }
  561. // Collect results for structured output
  562. const results: { file: string; displayPath: string; title: string; body: string; context: string | null; skipped: boolean; skipReason?: string }[] = [];
  563. for (const file of files) {
  564. const context = getContextForFile(db, file.filepath);
  565. // Check size limit
  566. if (file.bodyLength > maxBytes) {
  567. results.push({
  568. file: file.filepath,
  569. displayPath: file.displayPath,
  570. title: file.displayPath.split('/').pop() || file.displayPath,
  571. body: "",
  572. context,
  573. skipped: true,
  574. skipReason: `File too large (${Math.round(file.bodyLength / 1024)}KB > ${Math.round(maxBytes / 1024)}KB). Use 'qmd get ${file.displayPath}' to retrieve.`,
  575. });
  576. continue;
  577. }
  578. const doc = db.prepare(`SELECT body, title FROM documents WHERE filepath = ? AND active = 1`).get(file.filepath) as { body: string; title: string } | null;
  579. if (!doc) continue;
  580. let body = doc.body;
  581. // Apply line limit if specified
  582. if (maxLines !== undefined) {
  583. const lines = body.split('\n');
  584. body = lines.slice(0, maxLines).join('\n');
  585. if (lines.length > maxLines) {
  586. body += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
  587. }
  588. }
  589. results.push({
  590. file: file.filepath,
  591. displayPath: file.displayPath,
  592. title: doc.title || file.displayPath.split('/').pop() || file.displayPath,
  593. body,
  594. context,
  595. skipped: false,
  596. });
  597. }
  598. db.close();
  599. // Output based on format
  600. if (format === "json") {
  601. const output = results.map(r => ({
  602. file: r.displayPath,
  603. title: r.title,
  604. ...(r.context && { context: r.context }),
  605. ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
  606. }));
  607. console.log(JSON.stringify(output, null, 2));
  608. } else if (format === "csv") {
  609. const escapeField = (val: string | null): string => {
  610. if (val === null || val === undefined) return "";
  611. const str = String(val);
  612. if (str.includes(",") || str.includes('"') || str.includes("\n")) {
  613. return `"${str.replace(/"/g, '""')}"`;
  614. }
  615. return str;
  616. };
  617. console.log("file,title,context,skipped,body");
  618. for (const r of results) {
  619. console.log([r.displayPath, r.title, r.context || "", r.skipped ? "true" : "false", r.skipped ? r.skipReason : r.body].map(escapeField).join(","));
  620. }
  621. } else if (format === "files") {
  622. for (const r of results) {
  623. const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
  624. const status = r.skipped ? "[SKIPPED]" : "";
  625. console.log(`${r.displayPath}${ctx}${status ? `,${status}` : ""}`);
  626. }
  627. } else if (format === "md") {
  628. for (const r of results) {
  629. console.log(`## ${r.displayPath}\n`);
  630. if (r.title && r.title !== r.displayPath) console.log(`**Title:** ${r.title}\n`);
  631. if (r.context) console.log(`**Context:** ${r.context}\n`);
  632. if (r.skipped) {
  633. console.log(`> ${r.skipReason}\n`);
  634. } else {
  635. console.log("```");
  636. console.log(r.body);
  637. console.log("```\n");
  638. }
  639. }
  640. } else if (format === "xml") {
  641. console.log('<?xml version="1.0" encoding="UTF-8"?>');
  642. console.log("<documents>");
  643. for (const r of results) {
  644. console.log(" <document>");
  645. console.log(` <file>${escapeXml(r.displayPath)}</file>`);
  646. console.log(` <title>${escapeXml(r.title)}</title>`);
  647. if (r.context) console.log(` <context>${escapeXml(r.context)}</context>`);
  648. if (r.skipped) {
  649. console.log(` <skipped>true</skipped>`);
  650. console.log(` <reason>${escapeXml(r.skipReason || "")}</reason>`);
  651. } else {
  652. console.log(` <body>${escapeXml(r.body)}</body>`);
  653. }
  654. console.log(" </document>");
  655. }
  656. console.log("</documents>");
  657. } else {
  658. // CLI format (default)
  659. for (const r of results) {
  660. console.log(`\n${'='.repeat(60)}`);
  661. console.log(`File: ${r.displayPath}`);
  662. console.log(`${'='.repeat(60)}\n`);
  663. if (r.skipped) {
  664. console.log(`[SKIPPED: ${r.skipReason}]`);
  665. continue;
  666. }
  667. if (r.context) {
  668. console.log(`Folder Context: ${r.context}\n---\n`);
  669. }
  670. console.log(r.body);
  671. }
  672. }
  673. }
  674. // Get context for a filepath (finds most specific matching path prefix)
  675. function getContextForFile(db: Database, filepath: string): string | null {
  676. // Find all matching prefixes and return the longest (most specific) one
  677. const result = db.prepare(`
  678. SELECT context FROM path_contexts
  679. WHERE ? LIKE path_prefix || '%'
  680. ORDER BY LENGTH(path_prefix) DESC
  681. LIMIT 1
  682. `).get(filepath) as { context: string } | null;
  683. return result?.context || null;
  684. }
  685. async function dropCollection(globPattern: string): Promise<void> {
  686. const db = getDb();
  687. const pwd = getPwd();
  688. const collection = db.prepare(`SELECT id FROM collections WHERE pwd = ? AND glob_pattern = ?`).get(pwd, globPattern) as { id: number } | null;
  689. if (!collection) {
  690. console.log(`No collection found for ${pwd} with pattern ${globPattern}`);
  691. db.close();
  692. return;
  693. }
  694. // Delete documents in this collection
  695. const deleted = db.prepare(`DELETE FROM documents WHERE collection_id = ?`).run(collection.id);
  696. // Delete the collection
  697. db.prepare(`DELETE FROM collections WHERE id = ?`).run(collection.id);
  698. console.log(`Dropped collection: ${pwd} (${globPattern})`);
  699. console.log(`Removed ${deleted.changes} documents`);
  700. console.log(`(Vectors kept for potential reuse)`);
  701. db.close();
  702. }
  703. async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise<void> {
  704. const db = getDb();
  705. const pwd = getPwd();
  706. const now = new Date().toISOString();
  707. const excludeDirs = ["node_modules", ".git", ".cache", "vendor", "dist", "build"];
  708. // Clear Ollama cache on index
  709. clearCache(db);
  710. // Get or create collection for this (pwd, glob)
  711. const collectionId = getOrCreateCollection(db, pwd, globPattern);
  712. console.log(`Collection: ${pwd} (${globPattern})`);
  713. progress.indeterminate();
  714. const glob = new Glob(globPattern);
  715. const files: string[] = [];
  716. for await (const file of glob.scan({ cwd: pwd, onlyFiles: true, followSymlinks: true })) {
  717. // Skip node_modules, hidden folders (.*), and other common excludes
  718. const parts = file.split("/");
  719. const shouldSkip = parts.some(part =>
  720. part === "node_modules" ||
  721. part.startsWith(".") ||
  722. excludeDirs.includes(part)
  723. );
  724. if (!shouldSkip) {
  725. files.push(file);
  726. }
  727. }
  728. const total = files.length;
  729. if (total === 0) {
  730. progress.clear();
  731. console.log("No files found matching pattern.");
  732. db.close();
  733. return;
  734. }
  735. const insertStmt = db.prepare(`INSERT INTO documents (collection_id, name, title, hash, filepath, display_path, body, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`);
  736. const deactivateStmt = db.prepare(`UPDATE documents SET active = 0 WHERE collection_id = ? AND filepath = ? AND active = 1`);
  737. const findActiveStmt = db.prepare(`SELECT id, hash, title, display_path FROM documents WHERE collection_id = ? AND filepath = ? AND active = 1`);
  738. const findActiveAnyCollectionStmt = db.prepare(`SELECT id, collection_id, hash, title, display_path FROM documents WHERE filepath = ? AND active = 1`);
  739. const updateTitleStmt = db.prepare(`UPDATE documents SET title = ?, modified_at = ? WHERE id = ?`);
  740. const updateDisplayPathStmt = db.prepare(`UPDATE documents SET display_path = ? WHERE id = ?`);
  741. // Collect all existing display_paths for uniqueness check
  742. const existingDisplayPaths = new Set<string>(
  743. (db.prepare(`SELECT display_path FROM documents WHERE active = 1 AND display_path != ''`).all() as { display_path: string }[])
  744. .map(r => r.display_path)
  745. );
  746. let indexed = 0, updated = 0, unchanged = 0, processed = 0;
  747. const seenFiles = new Set<string>();
  748. const startTime = Date.now();
  749. for (const relativeFile of files) {
  750. const filepath = getRealPath(resolve(pwd, relativeFile));
  751. seenFiles.add(filepath);
  752. const content = await Bun.file(filepath).text();
  753. const hash = await hashContent(content);
  754. const name = relativeFile.replace(/\.md$/, "").split("/").pop() || relativeFile;
  755. const title = extractTitle(content, relativeFile);
  756. // First check if file exists in THIS collection
  757. const existing = findActiveStmt.get(collectionId, filepath) as { id: number; hash: string; title: string; display_path: string } | null;
  758. if (existing) {
  759. if (existing.hash === hash) {
  760. // Hash unchanged, but check if title needs updating
  761. if (existing.title !== title) {
  762. updateTitleStmt.run(title, now, existing.id);
  763. updated++;
  764. } else {
  765. unchanged++;
  766. }
  767. // Update display_path if empty
  768. if (!existing.display_path) {
  769. const displayPath = computeDisplayPath(filepath, pwd, existingDisplayPaths);
  770. updateDisplayPathStmt.run(displayPath, existing.id);
  771. existingDisplayPaths.add(displayPath);
  772. }
  773. } else {
  774. // Content changed - deactivate old, insert new
  775. existingDisplayPaths.delete(existing.display_path);
  776. deactivateStmt.run(collectionId, filepath);
  777. updated++;
  778. const stat = await Bun.file(filepath).stat();
  779. const displayPath = computeDisplayPath(filepath, pwd, existingDisplayPaths);
  780. insertStmt.run(collectionId, name, title, hash, filepath, displayPath, content, stat ? new Date(stat.birthtime).toISOString() : now, stat ? new Date(stat.mtime).toISOString() : now);
  781. existingDisplayPaths.add(displayPath);
  782. }
  783. } else {
  784. // Check if file exists in ANY collection (would violate unique constraint)
  785. const existingAnywhere = findActiveAnyCollectionStmt.get(filepath) as { id: number; collection_id: number; hash: string; title: string; display_path: string } | null;
  786. if (existingAnywhere) {
  787. // File already indexed in another collection - skip it
  788. unchanged++;
  789. } else {
  790. indexed++;
  791. const stat = await Bun.file(filepath).stat();
  792. const displayPath = computeDisplayPath(filepath, pwd, existingDisplayPaths);
  793. insertStmt.run(collectionId, name, title, hash, filepath, displayPath, content, stat ? new Date(stat.birthtime).toISOString() : now, stat ? new Date(stat.mtime).toISOString() : now);
  794. existingDisplayPaths.add(displayPath);
  795. }
  796. }
  797. processed++;
  798. progress.set((processed / total) * 100);
  799. const elapsed = (Date.now() - startTime) / 1000;
  800. const rate = processed / elapsed;
  801. const remaining = (total - processed) / rate;
  802. const eta = processed > 2 ? ` ETA: ${formatETA(remaining)}` : "";
  803. process.stderr.write(`\rIndexing: ${processed}/${total}${eta} `);
  804. }
  805. // Deactivate documents in this collection that no longer exist
  806. const allActive = db.prepare(`SELECT filepath FROM documents WHERE collection_id = ? AND active = 1`).all(collectionId) as { filepath: string }[];
  807. let removed = 0;
  808. for (const row of allActive) {
  809. if (!seenFiles.has(row.filepath)) {
  810. deactivateStmt.run(collectionId, row.filepath);
  811. removed++;
  812. }
  813. }
  814. // Check if vector index needs updating
  815. const needsEmbedding = getHashesNeedingEmbedding(db);
  816. progress.clear();
  817. console.log(`\nIndexed: ${indexed} new, ${updated} updated, ${unchanged} unchanged, ${removed} removed`);
  818. if (needsEmbedding > 0) {
  819. console.log(`\nRun 'qmd embed' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
  820. }
  821. db.close();
  822. }
  823. function renderProgressBar(percent: number, width: number = 30): string {
  824. const filled = Math.round((percent / 100) * width);
  825. const empty = width - filled;
  826. const bar = "█".repeat(filled) + "░".repeat(empty);
  827. return bar;
  828. }
  829. async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean = false): Promise<void> {
  830. const db = getDb();
  831. const now = new Date().toISOString();
  832. // If force, clear all vectors
  833. if (force) {
  834. console.log(`${c.yellow}Force re-indexing: clearing all vectors...${c.reset}`);
  835. db.exec(`DELETE FROM content_vectors`);
  836. db.exec(`DROP TABLE IF EXISTS vectors_vec`);
  837. }
  838. // Find unique hashes that need embedding (from active documents)
  839. // Use MIN(filepath) to get one representative filepath per hash
  840. const hashesToEmbed = db.prepare(`
  841. SELECT d.hash, d.body, MIN(d.filepath) as filepath, MIN(d.display_path) as display_path
  842. FROM documents d
  843. LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
  844. WHERE d.active = 1 AND v.hash IS NULL
  845. GROUP BY d.hash
  846. `).all() as { hash: string; body: string; filepath: string; display_path: string }[];
  847. if (hashesToEmbed.length === 0) {
  848. console.log(`${c.green}✓ All content hashes already have embeddings.${c.reset}`);
  849. db.close();
  850. return;
  851. }
  852. // Prepare documents with chunks
  853. type ChunkItem = { hash: string; title: string; text: string; seq: number; pos: number; bytes: number; displayName: string };
  854. const allChunks: ChunkItem[] = [];
  855. let multiChunkDocs = 0;
  856. for (const item of hashesToEmbed) {
  857. const encoder = new TextEncoder();
  858. const bodyBytes = encoder.encode(item.body).length;
  859. if (bodyBytes === 0) continue; // Skip empty
  860. const title = extractTitle(item.body, item.filepath);
  861. const displayName = item.display_path || item.filepath;
  862. const chunks = chunkDocument(item.body, CHUNK_BYTE_SIZE);
  863. if (chunks.length > 1) multiChunkDocs++;
  864. for (let seq = 0; seq < chunks.length; seq++) {
  865. allChunks.push({
  866. hash: item.hash,
  867. title,
  868. text: chunks[seq].text,
  869. seq,
  870. pos: chunks[seq].pos,
  871. bytes: encoder.encode(chunks[seq].text).length,
  872. displayName,
  873. });
  874. }
  875. }
  876. if (allChunks.length === 0) {
  877. console.log(`${c.green}✓ No non-empty documents to embed.${c.reset}`);
  878. db.close();
  879. return;
  880. }
  881. const totalBytes = allChunks.reduce((sum, c) => sum + c.bytes, 0);
  882. const totalChunks = allChunks.length;
  883. const totalDocs = hashesToEmbed.length;
  884. console.log(`${c.bold}Embedding ${totalDocs} documents${c.reset} ${c.dim}(${totalChunks} chunks, ${formatBytes(totalBytes)})${c.reset}`);
  885. if (multiChunkDocs > 0) {
  886. console.log(`${c.dim}${multiChunkDocs} documents split into multiple chunks${c.reset}`);
  887. }
  888. console.log(`${c.dim}Model: ${model}${c.reset}\n`);
  889. // Hide cursor during embedding
  890. cursor.hide();
  891. // Get embedding dimensions from first chunk
  892. progress.indeterminate();
  893. const firstEmbedding = await getEmbedding(allChunks[0].text, model, false, allChunks[0].title);
  894. ensureVecTable(db, firstEmbedding.length);
  895. const insertVecStmt = db.prepare(`INSERT OR REPLACE INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`);
  896. const insertContentVectorStmt = db.prepare(`INSERT OR REPLACE INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, ?, ?, ?, ?)`);
  897. let chunksEmbedded = 0, errors = 0, bytesProcessed = 0;
  898. const startTime = Date.now();
  899. // Insert first chunk
  900. const firstHashSeq = `${allChunks[0].hash}_${allChunks[0].seq}`;
  901. insertVecStmt.run(firstHashSeq, new Float32Array(firstEmbedding));
  902. insertContentVectorStmt.run(allChunks[0].hash, allChunks[0].seq, allChunks[0].pos, model, now);
  903. chunksEmbedded++;
  904. bytesProcessed += allChunks[0].bytes;
  905. for (let i = 1; i < allChunks.length; i++) {
  906. const chunk = allChunks[i];
  907. try {
  908. const embedding = await getEmbedding(chunk.text, model, false, chunk.title);
  909. const hashSeq = `${chunk.hash}_${chunk.seq}`;
  910. insertVecStmt.run(hashSeq, new Float32Array(embedding));
  911. insertContentVectorStmt.run(chunk.hash, chunk.seq, chunk.pos, model, now);
  912. chunksEmbedded++;
  913. bytesProcessed += chunk.bytes;
  914. } catch (err) {
  915. errors++;
  916. bytesProcessed += chunk.bytes;
  917. progress.error();
  918. console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}: ${err}${c.reset}`);
  919. }
  920. const percent = (bytesProcessed / totalBytes) * 100;
  921. progress.set(percent);
  922. const elapsed = (Date.now() - startTime) / 1000;
  923. const bytesPerSec = bytesProcessed / elapsed;
  924. const remainingBytes = totalBytes - bytesProcessed;
  925. const etaSec = remainingBytes / bytesPerSec;
  926. const bar = renderProgressBar(percent);
  927. const percentStr = percent.toFixed(0).padStart(3);
  928. const throughput = `${formatBytes(bytesPerSec)}/s`;
  929. const eta = elapsed > 2 ? formatETA(etaSec) : "...";
  930. const errStr = errors > 0 ? ` ${c.yellow}${errors} err${c.reset}` : "";
  931. process.stderr.write(`\r${c.cyan}${bar}${c.reset} ${c.bold}${percentStr}%${c.reset} ${c.dim}${chunksEmbedded}/${totalChunks}${c.reset}${errStr} ${c.dim}${throughput} ETA ${eta}${c.reset} `);
  932. }
  933. progress.clear();
  934. cursor.show();
  935. const totalTimeSec = (Date.now() - startTime) / 1000;
  936. const avgThroughput = formatBytes(totalBytes / totalTimeSec);
  937. console.log(`\r${c.green}${renderProgressBar(100)}${c.reset} ${c.bold}100%${c.reset} `);
  938. console.log(`\n${c.green}✓ Done!${c.reset} Embedded ${c.bold}${chunksEmbedded}${c.reset} chunks from ${c.bold}${totalDocs}${c.reset} documents in ${c.bold}${formatETA(totalTimeSec)}${c.reset} ${c.dim}(${avgThroughput}/s)${c.reset}`);
  939. if (errors > 0) {
  940. console.log(`${c.yellow}⚠ ${errors} chunks failed${c.reset}`);
  941. }
  942. db.close();
  943. }
  944. // Sanitize a term for FTS5: remove punctuation except apostrophes
  945. function sanitizeFTS5Term(term: string): string {
  946. // Remove all non-alphanumeric except apostrophes (for contractions like "don't")
  947. return term.replace(/[^\w']/g, '').trim();
  948. }
  949. // Build FTS5 query: phrase-aware with fallback to individual terms
  950. function buildFTS5Query(query: string): string {
  951. // Sanitize the full query for phrase matching
  952. const sanitizedQuery = query.replace(/[^\w\s']/g, '').trim();
  953. const terms = query
  954. .split(/\s+/)
  955. .map(sanitizeFTS5Term)
  956. .filter(term => term.length >= 2); // Skip single chars and empty
  957. if (terms.length === 0) return "";
  958. if (terms.length === 1) return `"${terms[0].replace(/"/g, '""')}"`;
  959. // Strategy: exact phrase OR proximity match OR individual terms
  960. // Exact phrase matches rank highest, then close proximity, then any term
  961. const phrase = `"${sanitizedQuery.replace(/"/g, '""')}"`;
  962. const quotedTerms = terms.map(t => `"${t.replace(/"/g, '""')}"`);
  963. // FTS5 NEAR syntax: NEAR(term1 term2, distance)
  964. const nearPhrase = `NEAR(${quotedTerms.join(' ')}, 10)`;
  965. const orTerms = quotedTerms.join(' OR ');
  966. // Exact phrase > proximity > any term
  967. return `(${phrase}) OR (${nearPhrase}) OR (${orTerms})`;
  968. }
  969. // Normalize BM25 score to 0-1 range using sigmoid
  970. function normalizeBM25(score: number): number {
  971. // BM25 scores are negative in SQLite (lower = better)
  972. // Typical range: -15 (excellent) to -2 (weak match)
  973. // Map to 0-1 where higher is better
  974. const absScore = Math.abs(score);
  975. // Sigmoid-ish normalization: maps ~2-15 range to ~0.1-0.95
  976. return 1 / (1 + Math.exp(-(absScore - 5) / 3));
  977. }
  978. // Get collection ID by name (matches pwd suffix)
  979. function getCollectionIdByName(db: Database, name: string): number | null {
  980. const result = db.prepare(`SELECT id FROM collections WHERE pwd LIKE ? ORDER BY LENGTH(pwd) DESC LIMIT 1`).get(`%${name}`) as { id: number } | null;
  981. return result?.id || null;
  982. }
  983. function searchFTS(db: Database, query: string, limit: number = 20, collectionId?: number): SearchResult[] {
  984. const ftsQuery = buildFTS5Query(query);
  985. if (!ftsQuery) return [];
  986. // BM25 weights: name=10, body=1 (title matches ranked higher)
  987. let sql = `
  988. SELECT d.filepath, d.display_path, d.title, d.body, bm25(documents_fts, 10.0, 1.0) as score
  989. FROM documents_fts f
  990. JOIN documents d ON d.id = f.rowid
  991. WHERE documents_fts MATCH ? AND d.active = 1
  992. `;
  993. const params: (string | number)[] = [ftsQuery];
  994. if (collectionId !== undefined) {
  995. sql += ` AND d.collection_id = ?`;
  996. params.push(collectionId);
  997. }
  998. sql += ` ORDER BY score LIMIT ?`;
  999. params.push(limit);
  1000. const stmt = db.prepare(sql);
  1001. const results = stmt.all(...params) as { filepath: string; display_path: string; title: string; body: string; score: number }[];
  1002. return results.map(r => ({
  1003. file: r.filepath,
  1004. displayPath: r.display_path,
  1005. title: r.title,
  1006. body: r.body,
  1007. score: normalizeBM25(r.score),
  1008. source: "fts" as const,
  1009. }));
  1010. }
  1011. async function searchVec(db: Database, query: string, model: string, limit: number = 20, collectionId?: number): Promise<SearchResult[]> {
  1012. const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  1013. if (!tableExists) return [];
  1014. const queryEmbedding = await getEmbedding(query, model, true);
  1015. const queryVec = new Float32Array(queryEmbedding);
  1016. // Join: vectors_vec -> content_vectors -> documents
  1017. // Over-retrieve to handle multiple chunks per document, then dedupe
  1018. let sql = `
  1019. SELECT d.filepath, d.display_path, d.title, d.body, vec.distance, cv.pos
  1020. FROM vectors_vec vec
  1021. JOIN content_vectors cv ON vec.hash_seq = cv.hash || '_' || cv.seq
  1022. JOIN documents d ON d.hash = cv.hash AND d.active = 1
  1023. WHERE vec.embedding MATCH ? AND k = ?
  1024. `;
  1025. if (collectionId !== undefined) {
  1026. sql += ` AND d.collection_id = ${collectionId}`;
  1027. }
  1028. sql += ` ORDER BY vec.distance`;
  1029. const stmt = db.prepare(sql);
  1030. const rawResults = stmt.all(queryVec, limit * 3) as { filepath: string; display_path: string; title: string; body: string; distance: number; pos: number }[];
  1031. // Aggregate chunks per document: max score + small bonus for additional matches
  1032. const byFile = new Map<string, { filepath: string; displayPath: string; title: string; body: string; chunkCount: number; bestPos: number; bestDist: number }>();
  1033. for (const r of rawResults) {
  1034. const existing = byFile.get(r.filepath);
  1035. if (!existing) {
  1036. byFile.set(r.filepath, { filepath: r.filepath, displayPath: r.display_path, title: r.title, body: r.body, chunkCount: 1, bestPos: r.pos, bestDist: r.distance });
  1037. } else {
  1038. existing.chunkCount++;
  1039. if (r.distance < existing.bestDist) {
  1040. existing.bestDist = r.distance;
  1041. existing.bestPos = r.pos;
  1042. }
  1043. }
  1044. }
  1045. // Score = max chunk score + 0.02 bonus per additional chunk (capped at +0.1)
  1046. return Array.from(byFile.values())
  1047. .map(r => {
  1048. const maxScore = 1 / (1 + r.bestDist);
  1049. const bonusChunks = Math.min(r.chunkCount - 1, 5);
  1050. const bonus = bonusChunks * 0.02;
  1051. return {
  1052. file: r.filepath,
  1053. displayPath: r.displayPath,
  1054. title: r.title,
  1055. body: r.body,
  1056. score: maxScore + bonus,
  1057. source: "vec" as const,
  1058. chunkPos: r.bestPos,
  1059. };
  1060. })
  1061. .sort((a, b) => b.score - a.score)
  1062. .slice(0, limit);
  1063. }
  1064. function normalizeScores(results: SearchResult[]): SearchResult[] {
  1065. if (results.length === 0) return results;
  1066. const maxScore = Math.max(...results.map(r => r.score));
  1067. const minScore = Math.min(...results.map(r => r.score));
  1068. const range = maxScore - minScore || 1;
  1069. return results.map(r => ({ ...r, score: (r.score - minScore) / range }));
  1070. }
  1071. // Reciprocal Rank Fusion: combines multiple ranked lists
  1072. // RRF score = sum(1 / (k + rank)) across all lists where doc appears
  1073. // k=60 is standard, provides good balance between top and lower ranks
  1074. export type RankedResult = { file: string; displayPath: string; title: string; body: string; score: number };
  1075. function reciprocalRankFusion(
  1076. resultLists: RankedResult[][],
  1077. weights: number[] = [], // Weight per result list (default 1.0)
  1078. k: number = 60
  1079. ): RankedResult[] {
  1080. const scores = new Map<string, { score: number; displayPath: string; title: string; body: string; bestRank: number }>();
  1081. for (let listIdx = 0; listIdx < resultLists.length; listIdx++) {
  1082. const results = resultLists[listIdx];
  1083. const weight = weights[listIdx] ?? 1.0;
  1084. for (let rank = 0; rank < results.length; rank++) {
  1085. const doc = results[rank];
  1086. const rrfScore = weight / (k + rank + 1);
  1087. const existing = scores.get(doc.file);
  1088. if (existing) {
  1089. existing.score += rrfScore;
  1090. existing.bestRank = Math.min(existing.bestRank, rank);
  1091. } else {
  1092. scores.set(doc.file, { score: rrfScore, displayPath: doc.displayPath, title: doc.title, body: doc.body, bestRank: rank });
  1093. }
  1094. }
  1095. }
  1096. // Add bonus for best rank: documents that ranked #1-3 in any list get a boost
  1097. // This prevents dilution of exact matches by expansion queries
  1098. return Array.from(scores.entries())
  1099. .map(([file, { score, displayPath, title, body, bestRank }]) => {
  1100. let bonus = 0;
  1101. if (bestRank === 0) bonus = 0.05; // Ranked #1 somewhere
  1102. else if (bestRank <= 2) bonus = 0.02; // Ranked top-3 somewhere
  1103. return { file, displayPath, title, body, score: score + bonus };
  1104. })
  1105. .sort((a, b) => b.score - a.score);
  1106. }
  1107. type OutputOptions = {
  1108. format: OutputFormat;
  1109. full: boolean;
  1110. limit: number;
  1111. minScore: number;
  1112. all?: boolean;
  1113. collection?: string; // Filter by collection name (pwd suffix match)
  1114. };
  1115. // Extract snippet with more context lines for CLI display
  1116. function extractSnippetWithContext(body: string, query: string, contextLines = 3, chunkPos?: number): { line: number; snippet: string; hasMatch: boolean } {
  1117. // If chunkPos provided, focus search on that area
  1118. let lineOffset = 0;
  1119. let searchBody = body;
  1120. if (chunkPos && chunkPos > 0) {
  1121. const contextStart = Math.max(0, chunkPos - 200);
  1122. searchBody = body.slice(contextStart);
  1123. if (contextStart > 0) {
  1124. lineOffset = body.slice(0, contextStart).split('\n').length - 1;
  1125. }
  1126. }
  1127. const lines = searchBody.split('\n');
  1128. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
  1129. let bestLine = 0, bestScore = -1;
  1130. for (let i = 0; i < lines.length; i++) {
  1131. const lineLower = lines[i].toLowerCase();
  1132. let score = 0;
  1133. for (const term of queryTerms) {
  1134. if (lineLower.includes(term)) score++;
  1135. }
  1136. if (score > bestScore) {
  1137. bestScore = score;
  1138. bestLine = i;
  1139. }
  1140. }
  1141. // No query match found - return beginning of chunk area or file
  1142. if (bestScore <= 0) {
  1143. const preview = lines.slice(0, contextLines * 2).join('\n').trim();
  1144. return { line: lineOffset + 1, snippet: preview, hasMatch: false };
  1145. }
  1146. const startLine = Math.max(0, bestLine - contextLines);
  1147. const endLine = Math.min(lines.length, bestLine + contextLines + 1);
  1148. const snippet = lines.slice(startLine, endLine).join('\n').trim();
  1149. return { line: lineOffset + bestLine + 1, snippet, hasMatch: true };
  1150. }
  1151. // Highlight query terms in text (skip short words < 3 chars)
  1152. function highlightTerms(text: string, query: string): string {
  1153. if (!useColor) return text;
  1154. const terms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  1155. let result = text;
  1156. for (const term of terms) {
  1157. const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
  1158. result = result.replace(regex, `${c.yellow}${c.bold}$1${c.reset}`);
  1159. }
  1160. return result;
  1161. }
  1162. // Format score with color based on value
  1163. function formatScore(score: number): string {
  1164. const pct = (score * 100).toFixed(0).padStart(3);
  1165. if (!useColor) return `${pct}%`;
  1166. if (score >= 0.7) return `${c.green}${pct}%${c.reset}`;
  1167. if (score >= 0.4) return `${c.yellow}${pct}%${c.reset}`;
  1168. return `${c.dim}${pct}%${c.reset}`;
  1169. }
  1170. // Shorten directory path for display - relative to $HOME (used for context paths, not documents)
  1171. function shortPath(dirpath: string): string {
  1172. const home = homedir();
  1173. if (dirpath.startsWith(home)) {
  1174. return '~' + dirpath.slice(home.length);
  1175. }
  1176. return dirpath;
  1177. }
  1178. function outputResults(results: { file: string; displayPath: string; title: string; body: string; score: number; context?: string | null; chunkPos?: number }[], query: string, opts: OutputOptions): void {
  1179. const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit);
  1180. if (filtered.length === 0) {
  1181. console.log("No results found above minimum score threshold.");
  1182. return;
  1183. }
  1184. if (opts.format === "json") {
  1185. // JSON output for LLM consumption
  1186. const output = filtered.map(row => ({
  1187. score: Math.round(row.score * 100) / 100,
  1188. file: row.displayPath,
  1189. title: row.title,
  1190. ...(row.context && { context: row.context }),
  1191. ...(opts.full && { body: row.body }),
  1192. ...(!opts.full && { snippet: extractSnippet(row.body, query, 300, row.chunkPos).snippet }),
  1193. }));
  1194. console.log(JSON.stringify(output, null, 2));
  1195. } else if (opts.format === "files") {
  1196. // Simple score,filepath,context output
  1197. for (const row of filtered) {
  1198. const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
  1199. console.log(`${row.score.toFixed(2)},${row.displayPath}${ctx}`);
  1200. }
  1201. } else if (opts.format === "cli") {
  1202. for (let i = 0; i < filtered.length; i++) {
  1203. const row = filtered[i];
  1204. const { line, snippet, hasMatch } = extractSnippetWithContext(row.body, query, 2, row.chunkPos);
  1205. // Line 1: filepath
  1206. const path = row.displayPath;
  1207. const lineInfo = hasMatch ? `:${line}` : "";
  1208. console.log(`${c.cyan}${path}${c.dim}${lineInfo}${c.reset}`);
  1209. // Line 2: Title (if available)
  1210. if (row.title) {
  1211. console.log(`${c.bold}Title: ${row.title}${c.reset}`);
  1212. }
  1213. // Line 3: Context (if available)
  1214. if (row.context) {
  1215. console.log(`${c.dim}Context: ${row.context}${c.reset}`);
  1216. }
  1217. // Line 4: Score
  1218. const score = formatScore(row.score);
  1219. console.log(`Score: ${c.bold}${score}${c.reset}`);
  1220. console.log();
  1221. // Snippet with highlighting (no leading | chars for better word wrap)
  1222. const highlighted = highlightTerms(snippet, query);
  1223. console.log(highlighted);
  1224. // Double empty line between results
  1225. if (i < filtered.length - 1) console.log('\n');
  1226. }
  1227. } else if (opts.format === "md") {
  1228. for (const row of filtered) {
  1229. const heading = row.title || row.displayPath;
  1230. if (opts.full) {
  1231. console.log(`---\n# ${heading}\n\n${row.body}\n`);
  1232. } else {
  1233. const { snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
  1234. console.log(`---\n# ${heading}\n\n${snippet}\n`);
  1235. }
  1236. }
  1237. } else if (opts.format === "xml") {
  1238. for (const row of filtered) {
  1239. const titleAttr = row.title ? ` title="${row.title.replace(/"/g, '&quot;')}"` : "";
  1240. if (opts.full) {
  1241. console.log(`<file name="${row.displayPath}"${titleAttr}>\n${row.body}\n</file>\n`);
  1242. } else {
  1243. const { snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
  1244. console.log(`<file name="${row.displayPath}"${titleAttr}>\n${snippet}\n</file>\n`);
  1245. }
  1246. }
  1247. } else {
  1248. // CSV format
  1249. console.log("score,file,title,line,snippet");
  1250. for (const row of filtered) {
  1251. const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
  1252. const content = opts.full ? row.body : snippet;
  1253. console.log(`${row.score.toFixed(4)},${escapeCSV(row.displayPath)},${escapeCSV(row.title)},${line},${escapeCSV(content)}`);
  1254. }
  1255. }
  1256. }
  1257. function search(query: string, opts: OutputOptions): void {
  1258. const db = getDb();
  1259. // Resolve collection filter if specified
  1260. let collectionId: number | undefined;
  1261. if (opts.collection) {
  1262. collectionId = getCollectionIdByName(db, opts.collection) ?? undefined;
  1263. if (collectionId === undefined) {
  1264. console.error(`Collection not found: ${opts.collection}`);
  1265. db.close();
  1266. process.exit(1);
  1267. }
  1268. }
  1269. // Use large limit for --all, otherwise fetch more than needed and let outputResults filter
  1270. const fetchLimit = opts.all ? 100000 : Math.max(50, opts.limit * 2);
  1271. const results = searchFTS(db, query, fetchLimit, collectionId);
  1272. // Add context to results
  1273. const resultsWithContext = results.map(r => ({
  1274. ...r,
  1275. context: getContextForFile(db, r.file),
  1276. }));
  1277. db.close();
  1278. if (resultsWithContext.length === 0) {
  1279. console.log("No results found.");
  1280. return;
  1281. }
  1282. outputResults(resultsWithContext, query, opts);
  1283. }
  1284. async function vectorSearch(query: string, opts: OutputOptions, model: string = DEFAULT_EMBED_MODEL): Promise<void> {
  1285. const db = getDb();
  1286. // Resolve collection filter if specified
  1287. let collectionId: number | undefined;
  1288. if (opts.collection) {
  1289. collectionId = getCollectionIdByName(db, opts.collection) ?? undefined;
  1290. if (collectionId === undefined) {
  1291. console.error(`Collection not found: ${opts.collection}`);
  1292. db.close();
  1293. process.exit(1);
  1294. }
  1295. }
  1296. const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  1297. if (!tableExists) {
  1298. console.error("Vector index not found. Run 'qmd embed' first to create embeddings.");
  1299. db.close();
  1300. return;
  1301. }
  1302. // Check index health and warn about issues
  1303. checkIndexHealth(db);
  1304. // Expand query to multiple variations (with caching)
  1305. const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, db);
  1306. process.stderr.write(`Searching with ${queries.length} query variations...\n`);
  1307. // Collect results from all query variations
  1308. // For --all, fetch more results per query
  1309. const perQueryLimit = opts.all ? 500 : 20;
  1310. const allResults = new Map<string, { file: string; displayPath: string; title: string; body: string; score: number }>();
  1311. for (const q of queries) {
  1312. const vecResults = await searchVec(db, q, model, perQueryLimit, collectionId);
  1313. for (const r of vecResults) {
  1314. const existing = allResults.get(r.file);
  1315. if (!existing || r.score > existing.score) {
  1316. allResults.set(r.file, { file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score });
  1317. }
  1318. }
  1319. }
  1320. // Sort by max score and limit to requested count
  1321. const results = Array.from(allResults.values())
  1322. .sort((a, b) => b.score - a.score)
  1323. .slice(0, opts.limit)
  1324. .map(r => ({ ...r, context: getContextForFile(db, r.file) }));
  1325. db.close();
  1326. if (results.length === 0) {
  1327. console.log("No results found.");
  1328. return;
  1329. }
  1330. outputResults(results, query, { ...opts, limit: results.length }); // Already limited
  1331. }
  1332. async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL, db?: Database): Promise<string[]> {
  1333. process.stderr.write("Generating query variations...\n");
  1334. const prompt = `You are a search query expander. Given a search query, generate 2 alternative queries that would help find relevant documents.
  1335. Rules:
  1336. - Use synonyms and related terminology (e.g., "craft" → "craftsmanship", "quality", "excellence")
  1337. - Rephrase to capture different angles (e.g., "engineering culture" → "technical excellence", "developer practices")
  1338. - Keep proper nouns and named concepts exactly as written (e.g., "Build a Business", "Stripe", "Shopify")
  1339. - Each variation should be 3-8 words, natural search terms
  1340. - Do NOT just append words like "search" or "find" or "documents"
  1341. Query: "${query}"
  1342. Output exactly 2 variations, one per line, no numbering or bullets:`;
  1343. const requestBody = {
  1344. model,
  1345. prompt,
  1346. stream: false,
  1347. think: false,
  1348. options: { num_predict: 150 },
  1349. };
  1350. // Check cache
  1351. const cacheDb = db || getDb();
  1352. const cacheKey = getCacheKey(`${OLLAMA_URL}/api/generate`, requestBody);
  1353. const cached = getCachedResult(cacheDb, cacheKey);
  1354. let responseText: string;
  1355. if (cached) {
  1356. responseText = cached;
  1357. } else {
  1358. const response = await fetch(`${OLLAMA_URL}/api/generate`, {
  1359. method: "POST",
  1360. headers: { "Content-Type": "application/json" },
  1361. body: JSON.stringify(requestBody),
  1362. });
  1363. if (!response.ok) {
  1364. const errorText = await response.text();
  1365. if (errorText.includes("not found") || errorText.includes("does not exist")) {
  1366. await ensureModelAvailable(model);
  1367. if (!db) cacheDb.close();
  1368. return expandQuery(query, model, db);
  1369. }
  1370. if (!db) cacheDb.close();
  1371. return [query];
  1372. }
  1373. const data = await response.json() as { response: string };
  1374. responseText = data.response;
  1375. setCachedResult(cacheDb, cacheKey, responseText);
  1376. }
  1377. if (!db) cacheDb.close();
  1378. const lines = responseText.trim().split('\n')
  1379. .map(l => l.replace(/^[\d\.\-\*\"\s]+/, '').replace(/["\s]+$/, '').trim())
  1380. .filter(l => l.length > 2 && l.length < 100 && !l.startsWith('<') && !l.toLowerCase().includes('variation'))
  1381. .slice(0, 2);
  1382. const allQueries = [query, ...lines];
  1383. process.stderr.write(`${c.dim}Queries: ${allQueries.join(' | ')}${c.reset}\n`);
  1384. return allQueries;
  1385. }
  1386. async function querySearch(query: string, opts: OutputOptions, embedModel: string = DEFAULT_EMBED_MODEL, rerankModel: string = DEFAULT_RERANK_MODEL): Promise<void> {
  1387. const db = getDb();
  1388. // Resolve collection filter if specified
  1389. let collectionId: number | undefined;
  1390. if (opts.collection) {
  1391. collectionId = getCollectionIdByName(db, opts.collection) ?? undefined;
  1392. if (collectionId === undefined) {
  1393. console.error(`Collection not found: ${opts.collection}`);
  1394. db.close();
  1395. process.exit(1);
  1396. }
  1397. }
  1398. // Check index health and warn about issues
  1399. checkIndexHealth(db);
  1400. // Expand query to multiple variations (with caching)
  1401. const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, db);
  1402. process.stderr.write(`Searching with ${queries.length} query variations...\n`);
  1403. // Collect ranked result lists for RRF fusion
  1404. const rankedLists: RankedResult[][] = [];
  1405. const hasVectors = !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  1406. for (const q of queries) {
  1407. // FTS search - get ranked results
  1408. const ftsResults = searchFTS(db, q, 20, collectionId);
  1409. if (ftsResults.length > 0) {
  1410. rankedLists.push(ftsResults.map(r => ({ file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score })));
  1411. }
  1412. // Vector search - get ranked results
  1413. if (hasVectors) {
  1414. const vecResults = await searchVec(db, q, embedModel, 20, collectionId);
  1415. if (vecResults.length > 0) {
  1416. rankedLists.push(vecResults.map(r => ({ file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score })));
  1417. }
  1418. }
  1419. }
  1420. // Apply Reciprocal Rank Fusion to combine all ranked lists
  1421. // Give 2x weight to original query results (first 2 lists: FTS + vector)
  1422. const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
  1423. const fused = reciprocalRankFusion(rankedLists, weights);
  1424. const candidates = fused.slice(0, 30); // Over-retrieve for reranking
  1425. if (candidates.length === 0) {
  1426. console.log("No results found.");
  1427. db.close();
  1428. return;
  1429. }
  1430. // Rerank with the original query (with caching)
  1431. const reranked = await rerank(
  1432. query,
  1433. candidates.map(c => ({ file: c.file, text: c.body })),
  1434. rerankModel,
  1435. db
  1436. );
  1437. // Blend RRF position score with reranker score using position-aware weights
  1438. // Top retrieval results get more protection from reranker disagreement
  1439. const candidateMap = new Map(candidates.map(c => [c.file, { displayPath: c.displayPath, title: c.title, body: c.body }]));
  1440. const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1])); // 1-indexed rank
  1441. const finalResults = reranked.map(r => {
  1442. const rrfRank = rrfRankMap.get(r.file) || 30;
  1443. // Position-aware blending: top retrieval results preserved more
  1444. // Rank 1-3: 75% RRF, 25% reranker (trust retrieval for exact matches)
  1445. // Rank 4-10: 60% RRF, 40% reranker
  1446. // Rank 11+: 40% RRF, 60% reranker (trust reranker for lower-ranked)
  1447. let rrfWeight: number;
  1448. if (rrfRank <= 3) {
  1449. rrfWeight = 0.75;
  1450. } else if (rrfRank <= 10) {
  1451. rrfWeight = 0.60;
  1452. } else {
  1453. rrfWeight = 0.40;
  1454. }
  1455. const rrfScore = 1 / rrfRank; // Position-based: 1, 0.5, 0.33...
  1456. const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
  1457. const candidate = candidateMap.get(r.file);
  1458. return {
  1459. file: r.file,
  1460. displayPath: candidate?.displayPath || "",
  1461. title: candidate?.title || "",
  1462. body: candidate?.body || "",
  1463. score: blendedScore,
  1464. context: getContextForFile(db, r.file),
  1465. };
  1466. }).sort((a, b) => b.score - a.score);
  1467. db.close();
  1468. outputResults(finalResults, query, opts);
  1469. }
  1470. // Parse CLI arguments using util.parseArgs
  1471. function parseCLI() {
  1472. const { values, positionals } = parseArgs({
  1473. args: Bun.argv.slice(2), // Skip bun and script path
  1474. options: {
  1475. // Global options
  1476. index: { type: "string" },
  1477. help: { type: "boolean", short: "h" },
  1478. // Search options
  1479. n: { type: "string" },
  1480. "min-score": { type: "string" },
  1481. all: { type: "boolean" },
  1482. full: { type: "boolean" },
  1483. csv: { type: "boolean" },
  1484. md: { type: "boolean" },
  1485. xml: { type: "boolean" },
  1486. files: { type: "boolean" },
  1487. json: { type: "boolean" },
  1488. collection: { type: "string", short: "c" }, // Filter by collection
  1489. // Add options
  1490. drop: { type: "boolean" },
  1491. // Embed options
  1492. force: { type: "boolean", short: "f" },
  1493. // Get options
  1494. l: { type: "string" }, // max lines
  1495. from: { type: "string" }, // start line
  1496. "max-bytes": { type: "string" }, // max bytes for multi-get
  1497. },
  1498. allowPositionals: true,
  1499. strict: false, // Allow unknown options to pass through
  1500. });
  1501. // Set global index name in store
  1502. if (values.index) {
  1503. setCustomIndexName(values.index);
  1504. }
  1505. // Determine output format
  1506. let format: OutputFormat = "cli";
  1507. if (values.csv) format = "csv";
  1508. else if (values.md) format = "md";
  1509. else if (values.xml) format = "xml";
  1510. else if (values.files) format = "files";
  1511. else if (values.json) format = "json";
  1512. // Default limit: 20 for --files/--json, 5 otherwise
  1513. // --all means return all results (use very large limit)
  1514. const defaultLimit = (format === "files" || format === "json") ? 20 : 5;
  1515. const isAll = values.all || false;
  1516. const opts: OutputOptions = {
  1517. format,
  1518. full: values.full || false,
  1519. limit: isAll ? 100000 : (values.n ? parseInt(values.n, 10) || defaultLimit : defaultLimit),
  1520. minScore: values["min-score"] ? parseFloat(values["min-score"]) || 0 : 0,
  1521. all: isAll,
  1522. collection: values.collection as string | undefined,
  1523. };
  1524. return {
  1525. command: positionals[0] || "",
  1526. args: positionals.slice(1),
  1527. query: positionals.slice(1).join(" "),
  1528. opts,
  1529. values,
  1530. };
  1531. }
  1532. function showHelp(): void {
  1533. console.log("Usage:");
  1534. console.log(" qmd add [--drop] [glob] - Add/update collection from $PWD (default: **/*.md)");
  1535. console.log(" qmd add-context <path> <text> - Add context description for files under path");
  1536. console.log(" qmd get <file>[:line] [-l N] [--from N] - Get document (optionally from line, max N lines)");
  1537. console.log(" qmd multi-get <pattern> [-l N] [--max-bytes N] - Get multiple docs by glob or comma-separated list");
  1538. console.log(" qmd status - Show index status and collections");
  1539. console.log(" qmd update-all - Re-index all collections");
  1540. console.log(" qmd embed [-f] - Create vector embeddings (chunks ~6KB each)");
  1541. console.log(" qmd cleanup - Remove cache and orphaned data, vacuum DB");
  1542. console.log(" qmd search <query> - Full-text search (BM25)");
  1543. console.log(" qmd vsearch <query> - Vector similarity search");
  1544. console.log(" qmd query <query> - Combined search with query expansion + reranking");
  1545. console.log(" qmd mcp - Start MCP server (for AI agent integration)");
  1546. console.log("");
  1547. console.log("Global options:");
  1548. console.log(" --index <name> - Use custom index name (default: index)");
  1549. console.log("");
  1550. console.log("Search options:");
  1551. console.log(" -n <num> - Number of results (default: 5, or 20 for --files)");
  1552. console.log(" --all - Return all matches (use with --min-score to filter)");
  1553. console.log(" --min-score <num> - Minimum similarity score");
  1554. console.log(" --full - Output full document instead of snippet");
  1555. console.log(" --files - Output score,filepath,context (default: 20 results)");
  1556. console.log(" --json - JSON output with snippets (default: 20 results)");
  1557. console.log(" --csv - CSV output with snippets");
  1558. console.log(" --md - Markdown output");
  1559. console.log(" --xml - XML output");
  1560. console.log(" -c, --collection <name> - Filter results to a specific collection");
  1561. console.log("");
  1562. console.log("Multi-get options:");
  1563. console.log(" -l <num> - Maximum lines per file");
  1564. console.log(" --max-bytes <num> - Skip files larger than N bytes (default: 10240)");
  1565. console.log(" --json/--csv/--md/--xml/--files - Output format (same as search)");
  1566. console.log("");
  1567. console.log("Environment:");
  1568. console.log(" OLLAMA_URL - Ollama server URL (default: http://localhost:11434)");
  1569. console.log("");
  1570. console.log("Models:");
  1571. console.log(` Embedding: ${DEFAULT_EMBED_MODEL}`);
  1572. console.log(` Reranking: ${DEFAULT_RERANK_MODEL}`);
  1573. console.log("");
  1574. console.log(`Index: ${getDbPath()}`);
  1575. }
  1576. // Main CLI - only run if this is the main module
  1577. if (import.meta.main) {
  1578. const cli = parseCLI();
  1579. if (!cli.command || cli.values.help) {
  1580. showHelp();
  1581. process.exit(cli.values.help ? 0 : 1);
  1582. }
  1583. switch (cli.command) {
  1584. case "add": {
  1585. const globArg = cli.args[0];
  1586. // Treat "." as "use default glob in current directory"
  1587. const globPattern = (!globArg || globArg === ".") ? DEFAULT_GLOB : globArg;
  1588. if (cli.values.drop) {
  1589. await dropCollection(globPattern);
  1590. } else {
  1591. await indexFiles(globPattern);
  1592. }
  1593. break;
  1594. }
  1595. case "add-context": {
  1596. // qmd add-context <path> <context> OR qmd add-context <context> (uses .)
  1597. if (cli.args.length === 0) {
  1598. console.error("Usage: qmd add-context <path> <context>");
  1599. console.error(" qmd add-context . \"Description of files in current directory\"");
  1600. process.exit(1);
  1601. }
  1602. let pathArg: string;
  1603. let contextText: string;
  1604. if (cli.args.length === 1) {
  1605. // Single arg = context for current directory
  1606. pathArg = ".";
  1607. contextText = cli.args[0];
  1608. } else {
  1609. pathArg = cli.args[0];
  1610. contextText = cli.args.slice(1).join(" ");
  1611. }
  1612. await addContext(pathArg, contextText);
  1613. break;
  1614. }
  1615. case "get": {
  1616. if (!cli.args[0]) {
  1617. console.error("Usage: qmd get <filepath>[:line] [--from <line>] [-l <lines>]");
  1618. process.exit(1);
  1619. }
  1620. const fromLine = cli.values.from ? parseInt(cli.values.from as string, 10) : undefined;
  1621. const maxLines = cli.values.l ? parseInt(cli.values.l as string, 10) : undefined;
  1622. getDocument(cli.args[0], fromLine, maxLines);
  1623. break;
  1624. }
  1625. case "multi-get": {
  1626. if (!cli.args[0]) {
  1627. console.error("Usage: qmd multi-get <pattern> [-l <lines>] [--max-bytes <bytes>] [--json|--csv|--md|--xml|--files]");
  1628. console.error(" pattern: glob (e.g., 'journals/2025-05*.md') or comma-separated list");
  1629. process.exit(1);
  1630. }
  1631. const maxLinesMulti = cli.values.l ? parseInt(cli.values.l as string, 10) : undefined;
  1632. const maxBytes = cli.values["max-bytes"] ? parseInt(cli.values["max-bytes"] as string, 10) : DEFAULT_MULTI_GET_MAX_BYTES;
  1633. multiGet(cli.args[0], maxLinesMulti, maxBytes, cli.opts.format);
  1634. break;
  1635. }
  1636. case "status":
  1637. showStatus();
  1638. break;
  1639. case "update-all":
  1640. await updateAllCollections();
  1641. break;
  1642. case "embed":
  1643. await vectorIndex(DEFAULT_EMBED_MODEL, cli.values.force || false);
  1644. break;
  1645. case "search":
  1646. if (!cli.query) {
  1647. console.error("Usage: qmd search [options] <query>");
  1648. process.exit(1);
  1649. }
  1650. search(cli.query, cli.opts);
  1651. break;
  1652. case "vsearch":
  1653. if (!cli.query) {
  1654. console.error("Usage: qmd vsearch [options] <query>");
  1655. process.exit(1);
  1656. }
  1657. // Default min-score for vector search is 0.3
  1658. if (!cli.values["min-score"]) {
  1659. cli.opts.minScore = 0.3;
  1660. }
  1661. await vectorSearch(cli.query, cli.opts);
  1662. break;
  1663. case "query":
  1664. if (!cli.query) {
  1665. console.error("Usage: qmd query [options] <query>");
  1666. process.exit(1);
  1667. }
  1668. await querySearch(cli.query, cli.opts);
  1669. break;
  1670. case "mcp": {
  1671. const { startMcpServer } = await import("./mcp.js");
  1672. await startMcpServer();
  1673. break;
  1674. }
  1675. case "cleanup": {
  1676. const db = getDb();
  1677. // 1. Clear ollama_cache
  1678. const cacheCount = db.prepare(`SELECT COUNT(*) as c FROM ollama_cache`).get() as { c: number };
  1679. db.exec(`DELETE FROM ollama_cache`);
  1680. console.log(`${c.green}✓${c.reset} Cleared ${cacheCount.c} cached API responses`);
  1681. // 2. Remove orphaned vectors (no active document with that hash)
  1682. const orphanedVecs = db.prepare(`
  1683. SELECT COUNT(*) as c FROM content_vectors cv
  1684. WHERE NOT EXISTS (
  1685. SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
  1686. )
  1687. `).get() as { c: number };
  1688. if (orphanedVecs.c > 0) {
  1689. db.exec(`
  1690. DELETE FROM vectors_vec WHERE hash_seq IN (
  1691. SELECT cv.hash || '_' || cv.seq FROM content_vectors cv
  1692. WHERE NOT EXISTS (
  1693. SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
  1694. )
  1695. )
  1696. `);
  1697. db.exec(`
  1698. DELETE FROM content_vectors WHERE hash NOT IN (
  1699. SELECT hash FROM documents WHERE active = 1
  1700. )
  1701. `);
  1702. console.log(`${c.green}✓${c.reset} Removed ${orphanedVecs.c} orphaned embedding chunks`);
  1703. } else {
  1704. console.log(`${c.dim}No orphaned embeddings to remove${c.reset}`);
  1705. }
  1706. // 3. Count inactive documents
  1707. const inactiveDocs = db.prepare(`SELECT COUNT(*) as c FROM documents WHERE active = 0`).get() as { c: number };
  1708. if (inactiveDocs.c > 0) {
  1709. db.exec(`DELETE FROM documents WHERE active = 0`);
  1710. console.log(`${c.green}✓${c.reset} Removed ${inactiveDocs.c} inactive document records`);
  1711. }
  1712. // 4. Vacuum to reclaim space
  1713. db.exec(`VACUUM`);
  1714. console.log(`${c.green}✓${c.reset} Database vacuumed`);
  1715. db.close();
  1716. break;
  1717. }
  1718. default:
  1719. console.error(`Unknown command: ${cli.command}`);
  1720. console.error("Run 'qmd --help' for usage.");
  1721. process.exit(1);
  1722. }
  1723. } // end if (import.meta.main)