qmd.ts 89 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515
  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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
  7. import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
  8. import { z } from "zod";
  9. const HOME = Bun.env.HOME || "/tmp";
  10. function homedir(): string {
  11. return HOME;
  12. }
  13. function resolve(...paths: string[]): string {
  14. // Simple path resolution
  15. let result = paths[0].startsWith('/') ? '' : Bun.env.PWD || process.cwd();
  16. for (const p of paths) {
  17. if (p.startsWith('/')) {
  18. result = p;
  19. } else {
  20. result = result + '/' + p;
  21. }
  22. }
  23. // Normalize: remove // and resolve . and ..
  24. const parts = result.split('/').filter(Boolean);
  25. const normalized: string[] = [];
  26. for (const part of parts) {
  27. if (part === '..') normalized.pop();
  28. else if (part !== '.') normalized.push(part);
  29. }
  30. return '/' + normalized.join('/');
  31. }
  32. // On macOS, use Homebrew's SQLite which supports extensions
  33. if (process.platform === "darwin") {
  34. const homebrewSqlitePath = "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib";
  35. if (Bun.file(homebrewSqlitePath).size > 0) {
  36. Database.setCustomSQLite(homebrewSqlitePath);
  37. }
  38. }
  39. const DEFAULT_EMBED_MODEL = "embeddinggemma";
  40. const DEFAULT_RERANK_MODEL = "ExpedientFalcon/qwen3-reranker:0.6b-q8_0";
  41. const DEFAULT_QUERY_MODEL = "qwen3:0.6b";
  42. const DEFAULT_GLOB = "**/*.md";
  43. const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
  44. // Chunking: ~2000 tokens per chunk, ~3 bytes/token = 6KB
  45. const CHUNK_TOKEN_LENGTH = 2000;
  46. const CHUNK_BYTE_SIZE = 6 * 1024;
  47. // Terminal colors (respects NO_COLOR env)
  48. const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
  49. const c = {
  50. reset: useColor ? "\x1b[0m" : "",
  51. dim: useColor ? "\x1b[2m" : "",
  52. bold: useColor ? "\x1b[1m" : "",
  53. cyan: useColor ? "\x1b[36m" : "",
  54. yellow: useColor ? "\x1b[33m" : "",
  55. green: useColor ? "\x1b[32m" : "",
  56. magenta: useColor ? "\x1b[35m" : "",
  57. blue: useColor ? "\x1b[34m" : "",
  58. };
  59. // Global state for --index option
  60. let customIndexName: string | null = null;
  61. // Terminal cursor control
  62. const cursor = {
  63. hide() { process.stderr.write('\x1b[?25l'); },
  64. show() { process.stderr.write('\x1b[?25h'); },
  65. };
  66. // Ensure cursor is restored on exit
  67. process.on('SIGINT', () => { cursor.show(); process.exit(130); });
  68. process.on('SIGTERM', () => { cursor.show(); process.exit(143); });
  69. // Terminal progress bar using OSC 9;4 escape sequence
  70. const progress = {
  71. set(percent: number) {
  72. process.stderr.write(`\x1b]9;4;1;${Math.round(percent)}\x07`);
  73. },
  74. clear() {
  75. process.stderr.write(`\x1b]9;4;0\x07`);
  76. },
  77. indeterminate() {
  78. process.stderr.write(`\x1b]9;4;3\x07`);
  79. },
  80. error() {
  81. process.stderr.write(`\x1b]9;4;2\x07`);
  82. },
  83. };
  84. // Format seconds into human-readable ETA
  85. function formatETA(seconds: number): string {
  86. if (seconds < 60) return `${Math.round(seconds)}s`;
  87. if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
  88. return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
  89. }
  90. function getDbPath(): string {
  91. const cacheDir = Bun.env.XDG_CACHE_HOME || resolve(homedir(), ".cache");
  92. const qmdCacheDir = resolve(cacheDir, "qmd");
  93. // Ensure cache directory exists
  94. try { Bun.spawnSync(["mkdir", "-p", qmdCacheDir]); } catch {}
  95. const dbName = customIndexName || "index";
  96. return resolve(qmdCacheDir, `${dbName}.sqlite`);
  97. }
  98. function getPwd(): string {
  99. return process.env.PWD || process.cwd();
  100. }
  101. // Get canonical realpath, falling back to resolved path if file doesn't exist
  102. function getRealPath(path: string): string {
  103. try {
  104. const result = Bun.spawnSync(["realpath", path]);
  105. if (result.success) {
  106. return result.stdout.toString().trim();
  107. }
  108. } catch {}
  109. return resolve(path);
  110. }
  111. /*
  112. Schema:
  113. CREATE TABLE collections (
  114. id INTEGER PRIMARY KEY AUTOINCREMENT,
  115. pwd TEXT NOT NULL,
  116. glob_pattern TEXT NOT NULL,
  117. created_at TEXT NOT NULL,
  118. UNIQUE(pwd, glob_pattern)
  119. );
  120. CREATE TABLE documents (
  121. id INTEGER PRIMARY KEY AUTOINCREMENT,
  122. collection_id INTEGER NOT NULL,
  123. name TEXT NOT NULL,
  124. title TEXT NOT NULL,
  125. hash TEXT NOT NULL,
  126. filepath TEXT NOT NULL,
  127. body TEXT NOT NULL,
  128. created_at TEXT NOT NULL,
  129. modified_at TEXT NOT NULL,
  130. active INTEGER NOT NULL DEFAULT 1,
  131. FOREIGN KEY (collection_id) REFERENCES collections(id)
  132. );
  133. CREATE TABLE content_vectors (
  134. hash TEXT NOT NULL,
  135. seq INTEGER NOT NULL DEFAULT 0, -- chunk sequence (0, 1, 2...)
  136. pos INTEGER NOT NULL DEFAULT 0, -- character position in document
  137. model TEXT NOT NULL,
  138. embedded_at TEXT NOT NULL,
  139. PRIMARY KEY (hash, seq)
  140. );
  141. CREATE VIRTUAL TABLE vectors_vec USING vec0(
  142. hash_seq TEXT PRIMARY KEY, -- "{hash}_{seq}"
  143. embedding float[N]
  144. );
  145. CREATE VIRTUAL TABLE documents_fts USING fts5(...);
  146. */
  147. function getDb(): Database {
  148. const db = new Database(getDbPath());
  149. sqliteVec.load(db);
  150. db.exec("PRAGMA journal_mode = WAL");
  151. // Collections table
  152. db.exec(`
  153. CREATE TABLE IF NOT EXISTS collections (
  154. id INTEGER PRIMARY KEY AUTOINCREMENT,
  155. pwd TEXT NOT NULL,
  156. glob_pattern TEXT NOT NULL,
  157. created_at TEXT NOT NULL,
  158. context TEXT,
  159. UNIQUE(pwd, glob_pattern)
  160. )
  161. `);
  162. // Path-based context (more flexible than collection-level)
  163. db.exec(`
  164. CREATE TABLE IF NOT EXISTS path_contexts (
  165. id INTEGER PRIMARY KEY AUTOINCREMENT,
  166. path_prefix TEXT NOT NULL UNIQUE,
  167. context TEXT NOT NULL,
  168. created_at TEXT NOT NULL
  169. )
  170. `);
  171. db.exec(`CREATE INDEX IF NOT EXISTS idx_path_contexts_prefix ON path_contexts(path_prefix)`);
  172. // Cache table for Ollama API calls (not embeddings)
  173. db.exec(`
  174. CREATE TABLE IF NOT EXISTS ollama_cache (
  175. hash TEXT PRIMARY KEY,
  176. result TEXT NOT NULL,
  177. created_at TEXT NOT NULL
  178. )
  179. `);
  180. // Documents table with collection_id and full filepath
  181. db.exec(`
  182. CREATE TABLE IF NOT EXISTS documents (
  183. id INTEGER PRIMARY KEY AUTOINCREMENT,
  184. collection_id INTEGER NOT NULL,
  185. name TEXT NOT NULL,
  186. title TEXT NOT NULL,
  187. hash TEXT NOT NULL,
  188. filepath TEXT NOT NULL,
  189. display_path TEXT NOT NULL DEFAULT '',
  190. body TEXT NOT NULL,
  191. created_at TEXT NOT NULL,
  192. modified_at TEXT NOT NULL,
  193. active INTEGER NOT NULL DEFAULT 1,
  194. FOREIGN KEY (collection_id) REFERENCES collections(id)
  195. )
  196. `);
  197. // Migration: add display_path column if missing
  198. const docInfo = db.prepare(`PRAGMA table_info(documents)`).all() as { name: string }[];
  199. const hasDisplayPath = docInfo.some(col => col.name === 'display_path');
  200. if (!hasDisplayPath) {
  201. db.exec(`ALTER TABLE documents ADD COLUMN display_path TEXT NOT NULL DEFAULT ''`);
  202. }
  203. // Unique index on display_path (only for non-empty values)
  204. db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_display_path ON documents(display_path) WHERE display_path != '' AND active = 1`);
  205. // Content vectors keyed by (hash, seq) for chunked embeddings
  206. // Migration: check if old schema (no seq column) and recreate
  207. const cvInfo = db.prepare(`PRAGMA table_info(content_vectors)`).all() as { name: string }[];
  208. const hasSeqColumn = cvInfo.some(col => col.name === 'seq');
  209. if (cvInfo.length > 0 && !hasSeqColumn) {
  210. // Old schema without chunking - drop and recreate (embeddings need regenerating anyway)
  211. db.exec(`DROP TABLE IF EXISTS content_vectors`);
  212. db.exec(`DROP TABLE IF EXISTS vectors_vec`);
  213. }
  214. db.exec(`
  215. CREATE TABLE IF NOT EXISTS content_vectors (
  216. hash TEXT NOT NULL,
  217. seq INTEGER NOT NULL DEFAULT 0,
  218. pos INTEGER NOT NULL DEFAULT 0,
  219. model TEXT NOT NULL,
  220. embedded_at TEXT NOT NULL,
  221. PRIMARY KEY (hash, seq)
  222. )
  223. `);
  224. // FTS on documents
  225. db.exec(`
  226. CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
  227. name, body,
  228. content='documents',
  229. content_rowid='id',
  230. tokenize='porter unicode61'
  231. )
  232. `);
  233. db.exec(`
  234. CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
  235. INSERT INTO documents_fts(rowid, name, body) VALUES (new.id, new.name, new.body);
  236. END
  237. `);
  238. db.exec(`
  239. CREATE TRIGGER IF NOT EXISTS documents_ad AFTER DELETE ON documents BEGIN
  240. INSERT INTO documents_fts(documents_fts, rowid, name, body) VALUES('delete', old.id, old.name, old.body);
  241. END
  242. `);
  243. db.exec(`
  244. CREATE TRIGGER IF NOT EXISTS documents_au AFTER UPDATE ON documents BEGIN
  245. INSERT INTO documents_fts(documents_fts, rowid, name, body) VALUES('delete', old.id, old.name, old.body);
  246. INSERT INTO documents_fts(rowid, name, body) VALUES (new.id, new.name, new.body);
  247. END
  248. `);
  249. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_collection ON documents(collection_id, active)`);
  250. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_hash ON documents(hash)`);
  251. db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_filepath ON documents(filepath, active)`);
  252. // Ensure only one active document per filepath
  253. db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_filepath_active ON documents(filepath) WHERE active = 1`);
  254. return db;
  255. }
  256. function ensureVecTable(db: Database, dimensions: number): void {
  257. const tableInfo = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get() as { sql: string } | null;
  258. if (tableInfo) {
  259. // Check for correct dimensions and hash_seq key (not old 'hash' key)
  260. const match = tableInfo.sql.match(/float\[(\d+)\]/);
  261. const hasHashSeq = tableInfo.sql.includes('hash_seq');
  262. if (match && parseInt(match[1]) === dimensions && hasHashSeq) return;
  263. db.exec("DROP TABLE IF EXISTS vectors_vec");
  264. }
  265. // Use hash_seq as composite key: "{hash}_{seq}" (e.g., "abc123_0", "abc123_1")
  266. db.exec(`CREATE VIRTUAL TABLE vectors_vec USING vec0(hash_seq TEXT PRIMARY KEY, embedding float[${dimensions}])`);
  267. }
  268. function getHashesNeedingEmbedding(db: Database): number {
  269. // Check for hashes missing the first chunk (seq=0)
  270. const result = db.prepare(`
  271. SELECT COUNT(DISTINCT d.hash) as count
  272. FROM documents d
  273. LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
  274. WHERE d.active = 1 AND v.hash IS NULL
  275. `).get() as { count: number };
  276. return result.count;
  277. }
  278. // Check index health and print warnings/tips
  279. function checkIndexHealth(db: Database): void {
  280. const needsEmbedding = getHashesNeedingEmbedding(db);
  281. const totalDocs = (db.prepare(`SELECT COUNT(*) as count FROM documents WHERE active = 1`).get() as { count: number }).count;
  282. // Warn if many docs need embedding
  283. if (needsEmbedding > 0) {
  284. const pct = Math.round((needsEmbedding / totalDocs) * 100);
  285. if (pct >= 10) {
  286. process.stderr.write(`${c.yellow}Warning: ${needsEmbedding} documents (${pct}%) need embeddings. Run 'qmd embed' for better results.${c.reset}\n`);
  287. } else {
  288. process.stderr.write(`${c.dim}Tip: ${needsEmbedding} documents need embeddings. Run 'qmd embed' to index them.${c.reset}\n`);
  289. }
  290. }
  291. // Check if most recent document update is older than 2 weeks
  292. const mostRecent = db.prepare(`SELECT MAX(modified_at) as latest FROM documents WHERE active = 1`).get() as { latest: string | null };
  293. if (mostRecent?.latest) {
  294. const lastUpdate = new Date(mostRecent.latest);
  295. const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
  296. if (lastUpdate < twoWeeksAgo) {
  297. const daysAgo = Math.floor((Date.now() - lastUpdate.getTime()) / (24 * 60 * 60 * 1000));
  298. process.stderr.write(`${c.dim}Tip: Index last updated ${daysAgo} days ago. Run 'qmd update-all' to refresh.${c.reset}\n`);
  299. }
  300. }
  301. }
  302. async function hashContent(content: string): Promise<string> {
  303. const hash = new Bun.CryptoHasher("sha256");
  304. hash.update(content);
  305. return hash.digest("hex");
  306. }
  307. // Cache helpers for Ollama API calls (not embeddings)
  308. function getCacheKey(url: string, body: object): string {
  309. const hash = new Bun.CryptoHasher("sha256");
  310. hash.update(url);
  311. hash.update(JSON.stringify(body));
  312. return hash.digest("hex");
  313. }
  314. function getCachedResult(db: Database, cacheKey: string): string | null {
  315. const row = db.prepare(`SELECT result FROM ollama_cache WHERE hash = ?`).get(cacheKey) as { result: string } | null;
  316. return row?.result || null;
  317. }
  318. function setCachedResult(db: Database, cacheKey: string, result: string): void {
  319. const now = new Date().toISOString();
  320. db.prepare(`INSERT OR REPLACE INTO ollama_cache (hash, result, created_at) VALUES (?, ?, ?)`).run(cacheKey, result, now);
  321. // 1 in 100 chance to truncate to most recent 1000 entries
  322. if (Math.random() < 0.01) {
  323. db.exec(`DELETE FROM ollama_cache WHERE hash NOT IN (SELECT hash FROM ollama_cache ORDER BY created_at DESC LIMIT 1000)`);
  324. }
  325. }
  326. function clearCache(db: Database): void {
  327. db.exec(`DELETE FROM ollama_cache`);
  328. }
  329. // Extract title from first markdown headline, or use filename as fallback
  330. function extractTitle(content: string, filename: string): string {
  331. const match = content.match(/^##?\s+(.+)$/m);
  332. if (match) {
  333. const title = match[1].trim();
  334. // Skip generic "📝 Notes" heading, find next ## instead
  335. if (title === "📝 Notes" || title === "Notes") {
  336. const nextMatch = content.match(/^##\s+(.+)$/m);
  337. if (nextMatch) return nextMatch[1].trim();
  338. }
  339. return title;
  340. }
  341. return filename.replace(/\.md$/, "").split("/").pop() || filename;
  342. }
  343. // Format text for EmbeddingGemma
  344. function formatQueryForEmbedding(query: string): string {
  345. return `task: search result | query: ${query}`;
  346. }
  347. function formatDocForEmbedding(text: string, title?: string): string {
  348. return `title: ${title || "none"} | text: ${text}`;
  349. }
  350. // Chunk document into ~6KB pieces, breaking at word boundaries
  351. function chunkDocument(content: string, maxBytes: number = CHUNK_BYTE_SIZE): { text: string; pos: number }[] {
  352. const encoder = new TextEncoder();
  353. const totalBytes = encoder.encode(content).length;
  354. // Single chunk if small enough
  355. if (totalBytes <= maxBytes) {
  356. return [{ text: content, pos: 0 }];
  357. }
  358. const chunks: { text: string; pos: number }[] = [];
  359. let charPos = 0;
  360. while (charPos < content.length) {
  361. // Find chunk boundary at ~maxBytes
  362. let endPos = charPos;
  363. let byteCount = 0;
  364. // Advance character by character, counting bytes
  365. while (endPos < content.length && byteCount < maxBytes) {
  366. const charBytes = encoder.encode(content[endPos]).length;
  367. if (byteCount + charBytes > maxBytes) break;
  368. byteCount += charBytes;
  369. endPos++;
  370. }
  371. // Back up to word boundary (paragraph, newline, or space)
  372. if (endPos < content.length && endPos > charPos) {
  373. const slice = content.slice(charPos, endPos);
  374. // Prefer paragraph break, then sentence end, then newline, then space
  375. const paragraphBreak = slice.lastIndexOf('\n\n');
  376. const sentenceEnd = Math.max(
  377. slice.lastIndexOf('. '),
  378. slice.lastIndexOf('.\n'),
  379. slice.lastIndexOf('? '),
  380. slice.lastIndexOf('?\n'),
  381. slice.lastIndexOf('! '),
  382. slice.lastIndexOf('!\n')
  383. );
  384. const lineBreak = slice.lastIndexOf('\n');
  385. const spaceBreak = slice.lastIndexOf(' ');
  386. let breakPoint = -1;
  387. if (paragraphBreak > slice.length * 0.5) {
  388. breakPoint = paragraphBreak + 2; // Include the double newline
  389. } else if (sentenceEnd > slice.length * 0.5) {
  390. breakPoint = sentenceEnd + 2; // Include period and space
  391. } else if (lineBreak > slice.length * 0.3) {
  392. breakPoint = lineBreak + 1;
  393. } else if (spaceBreak > slice.length * 0.3) {
  394. breakPoint = spaceBreak + 1;
  395. }
  396. if (breakPoint > 0) {
  397. endPos = charPos + breakPoint;
  398. }
  399. }
  400. // Ensure we make progress (at least one character)
  401. if (endPos <= charPos) {
  402. endPos = charPos + 1;
  403. }
  404. chunks.push({ text: content.slice(charPos, endPos), pos: charPos });
  405. charPos = endPos;
  406. }
  407. return chunks;
  408. }
  409. // Compute unique display path for a document
  410. // Always include at least parent folder + filename, add more parent dirs until unique
  411. function computeDisplayPath(
  412. filepath: string,
  413. collectionPath: string,
  414. existingPaths: Set<string>
  415. ): string {
  416. // Get path relative to collection (include collection dir name)
  417. const collectionDir = collectionPath.replace(/\/$/, '');
  418. const collectionName = collectionDir.split('/').pop() || '';
  419. let relativePath: string;
  420. if (filepath.startsWith(collectionDir + '/')) {
  421. // filepath is under collection: use collection name + relative path
  422. relativePath = collectionName + filepath.slice(collectionDir.length);
  423. } else {
  424. // Fallback: just use the filepath
  425. relativePath = filepath;
  426. }
  427. const parts = relativePath.split('/').filter(p => p.length > 0);
  428. // Always include at least parent folder + filename (minimum 2 parts if available)
  429. // Then add more parent dirs until unique
  430. const minParts = Math.min(2, parts.length);
  431. for (let i = parts.length - minParts; i >= 0; i--) {
  432. const candidate = parts.slice(i).join('/');
  433. if (!existingPaths.has(candidate)) {
  434. return candidate;
  435. }
  436. }
  437. // Absolute fallback: use full path (should be unique)
  438. return filepath;
  439. }
  440. // Auto-pull model if not found
  441. async function ensureModelAvailable(model: string): Promise<void> {
  442. try {
  443. const response = await fetch(`${OLLAMA_URL}/api/show`, {
  444. method: "POST",
  445. headers: { "Content-Type": "application/json" },
  446. body: JSON.stringify({ name: model }),
  447. });
  448. if (response.ok) return;
  449. } catch {
  450. // Continue to pull attempt
  451. }
  452. console.log(`Model ${model} not found. Pulling...`);
  453. progress.indeterminate();
  454. const pullResponse = await fetch(`${OLLAMA_URL}/api/pull`, {
  455. method: "POST",
  456. headers: { "Content-Type": "application/json" },
  457. body: JSON.stringify({ name: model, stream: false }),
  458. });
  459. if (!pullResponse.ok) {
  460. progress.error();
  461. throw new Error(`Failed to pull model ${model}: ${pullResponse.status} - ${await pullResponse.text()}`);
  462. }
  463. progress.clear();
  464. console.log(`Model ${model} pulled successfully.`);
  465. }
  466. async function getEmbedding(text: string, model: string, isQuery: boolean = false, title?: string, retried: boolean = false): Promise<number[]> {
  467. const input = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text, title);
  468. const response = await fetch(`${OLLAMA_URL}/api/embed`, {
  469. method: "POST",
  470. headers: { "Content-Type": "application/json" },
  471. body: JSON.stringify({ model, input }),
  472. });
  473. if (!response.ok) {
  474. const errorText = await response.text();
  475. if (!retried && (errorText.includes("not found") || errorText.includes("does not exist"))) {
  476. await ensureModelAvailable(model);
  477. return getEmbedding(text, model, isQuery, title, true);
  478. }
  479. throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
  480. }
  481. const data = await response.json() as { embeddings: number[][] };
  482. return data.embeddings[0];
  483. }
  484. // Qwen3-Reranker prompt format (trained for yes/no relevance classification)
  485. 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".`;
  486. function formatRerankPrompt(query: string, title: string, doc: string): string {
  487. 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.
  488. <Query>: ${query}
  489. <Document Title>: ${title}
  490. <Document>: ${doc}`;
  491. }
  492. type LogProb = { token: string; logprob: number };
  493. type RerankResponse = {
  494. response: string;
  495. logprobs?: LogProb[];
  496. };
  497. function parseRerankResponse(data: RerankResponse): number {
  498. if (!data.logprobs || data.logprobs.length === 0) {
  499. throw new Error("Reranker response missing logprobs");
  500. }
  501. const firstToken = data.logprobs[0];
  502. const token = firstToken.token.toLowerCase().trim();
  503. const confidence = Math.exp(firstToken.logprob);
  504. if (token === "yes") {
  505. return confidence;
  506. }
  507. if (token === "no") {
  508. return (1 - confidence) * 0.3;
  509. }
  510. throw new Error(`Unexpected reranker token: "${token}"`);
  511. }
  512. async function rerankSingle(prompt: string, model: string, db?: Database, retried: boolean = false): Promise<number> {
  513. // Use generate with raw template for qwen3-reranker format
  514. // Include empty <think> tags as per HuggingFace reference implementation
  515. const fullPrompt = `<|im_start|>system
  516. ${RERANK_SYSTEM}<|im_end|>
  517. <|im_start|>user
  518. ${prompt}<|im_end|>
  519. <|im_start|>assistant
  520. <think>
  521. </think>
  522. `;
  523. const requestBody = {
  524. model,
  525. prompt: fullPrompt,
  526. raw: true,
  527. stream: false,
  528. logprobs: true,
  529. options: { num_predict: 1 },
  530. };
  531. // Check cache
  532. const cacheKey = db ? getCacheKey(`${OLLAMA_URL}/api/generate`, requestBody) : "";
  533. if (db) {
  534. const cached = getCachedResult(db, cacheKey);
  535. if (cached) {
  536. const data = JSON.parse(cached) as RerankResponse;
  537. return parseRerankResponse(data);
  538. }
  539. }
  540. const response = await fetch(`${OLLAMA_URL}/api/generate`, {
  541. method: "POST",
  542. headers: { "Content-Type": "application/json" },
  543. body: JSON.stringify(requestBody),
  544. });
  545. if (!response.ok) {
  546. const errorText = await response.text();
  547. if (!retried && (errorText.includes("not found") || errorText.includes("does not exist"))) {
  548. await ensureModelAvailable(model);
  549. return rerankSingle(prompt, model, db, true);
  550. }
  551. throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
  552. }
  553. const data = await response.json() as RerankResponse;
  554. // Cache the result
  555. if (db) {
  556. setCachedResult(db, cacheKey, JSON.stringify(data));
  557. }
  558. return parseRerankResponse(data);
  559. }
  560. async function rerank(query: string, documents: { file: string; text: string }[], model: string = DEFAULT_RERANK_MODEL, db?: Database): Promise<{ file: string; score: number }[]> {
  561. const results: { file: string; score: number }[] = [];
  562. const total = documents.length;
  563. const PARALLEL = 5;
  564. process.stderr.write(`Reranking ${total} documents with ${model} (parallel: ${PARALLEL})...\n`);
  565. progress.indeterminate();
  566. // Process in parallel batches
  567. for (let i = 0; i < documents.length; i += PARALLEL) {
  568. const batch = documents.slice(i, i + PARALLEL);
  569. const batchResults = await Promise.all(
  570. batch.map(async (doc) => {
  571. try {
  572. // Extract title from filename for reranker context
  573. const title = doc.file.split('/').pop()?.replace(/\.md$/, '') || doc.file;
  574. const prompt = formatRerankPrompt(query, title, doc.text.slice(0, 4000));
  575. const score = await rerankSingle(prompt, model, db);
  576. return { file: doc.file, score };
  577. } catch (err) {
  578. return { file: doc.file, score: 0 };
  579. }
  580. })
  581. );
  582. results.push(...batchResults);
  583. const processed = Math.min(i + PARALLEL, total);
  584. progress.set((processed / total) * 100);
  585. process.stderr.write(`\rReranking: ${processed}/${total}`);
  586. }
  587. progress.clear();
  588. process.stderr.write("\n");
  589. return results.sort((a, b) => b.score - a.score);
  590. }
  591. function getOrCreateCollection(db: Database, pwd: string, globPattern: string): number {
  592. const now = new Date().toISOString();
  593. // Use INSERT OR IGNORE to handle race conditions, then SELECT
  594. db.prepare(`INSERT OR IGNORE INTO collections (pwd, glob_pattern, created_at) VALUES (?, ?, ?)`).run(pwd, globPattern, now);
  595. const existing = db.prepare(`SELECT id FROM collections WHERE pwd = ? AND glob_pattern = ?`).get(pwd, globPattern) as { id: number };
  596. return existing.id;
  597. }
  598. function cleanupDuplicateCollections(db: Database): void {
  599. // Remove duplicate collections keeping the oldest one
  600. db.exec(`
  601. DELETE FROM collections WHERE id NOT IN (
  602. SELECT MIN(id) FROM collections GROUP BY pwd, glob_pattern
  603. )
  604. `);
  605. // Remove bogus "." glob pattern entries (from earlier bug)
  606. db.exec(`DELETE FROM collections WHERE glob_pattern = '.'`);
  607. }
  608. function formatTimeAgo(date: Date): string {
  609. const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
  610. if (seconds < 60) return `${seconds}s ago`;
  611. const minutes = Math.floor(seconds / 60);
  612. if (minutes < 60) return `${minutes}m ago`;
  613. const hours = Math.floor(minutes / 60);
  614. if (hours < 24) return `${hours}h ago`;
  615. const days = Math.floor(hours / 24);
  616. return `${days}d ago`;
  617. }
  618. function formatBytes(bytes: number): string {
  619. if (bytes < 1024) return `${bytes} B`;
  620. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  621. if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  622. return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
  623. }
  624. function showStatus(): void {
  625. const dbPath = getDbPath();
  626. const db = getDb();
  627. // Cleanup any duplicate collections
  628. cleanupDuplicateCollections(db);
  629. // Index size
  630. let indexSize = 0;
  631. try {
  632. const stat = Bun.file(dbPath).size;
  633. indexSize = stat;
  634. } catch {}
  635. // Collections info
  636. const collections = db.prepare(`
  637. SELECT c.id, c.pwd, c.glob_pattern, c.created_at,
  638. COUNT(d.id) as doc_count,
  639. SUM(CASE WHEN d.active = 1 THEN 1 ELSE 0 END) as active_count,
  640. MAX(d.modified_at) as last_modified
  641. FROM collections c
  642. LEFT JOIN documents d ON d.collection_id = c.id
  643. GROUP BY c.id
  644. ORDER BY c.created_at DESC
  645. `).all() as { id: number; pwd: string; glob_pattern: string; created_at: string; doc_count: number; active_count: number; last_modified: string | null }[];
  646. // Overall stats
  647. const totalDocs = db.prepare(`SELECT COUNT(*) as count FROM documents WHERE active = 1`).get() as { count: number };
  648. const vectorCount = db.prepare(`SELECT COUNT(*) as count FROM content_vectors`).get() as { count: number };
  649. const needsEmbedding = getHashesNeedingEmbedding(db);
  650. // Most recent update across all collections
  651. const mostRecent = db.prepare(`SELECT MAX(modified_at) as latest FROM documents WHERE active = 1`).get() as { latest: string | null };
  652. console.log(`${c.bold}QMD Status${c.reset}\n`);
  653. console.log(`Index: ${dbPath}`);
  654. console.log(`Size: ${formatBytes(indexSize)}\n`);
  655. console.log(`${c.bold}Documents${c.reset}`);
  656. console.log(` Total: ${totalDocs.count} files indexed`);
  657. console.log(` Vectors: ${vectorCount.count} embedded`);
  658. if (needsEmbedding > 0) {
  659. console.log(` ${c.yellow}Pending: ${needsEmbedding} need embedding${c.reset} (run 'qmd embed')`);
  660. }
  661. if (mostRecent.latest) {
  662. const lastUpdate = new Date(mostRecent.latest);
  663. console.log(` Updated: ${formatTimeAgo(lastUpdate)}`);
  664. }
  665. // Get all path contexts
  666. const pathContexts = db.prepare(`SELECT path_prefix, context FROM path_contexts ORDER BY path_prefix`).all() as { path_prefix: string; context: string }[];
  667. if (collections.length > 0) {
  668. console.log(`\n${c.bold}Collections${c.reset}`);
  669. for (const col of collections) {
  670. const lastMod = col.last_modified ? formatTimeAgo(new Date(col.last_modified)) : "never";
  671. console.log(` ${c.cyan}${col.pwd}${c.reset}`);
  672. console.log(` ${col.glob_pattern} → ${col.active_count} docs (updated ${lastMod})`);
  673. // Show contexts that match this collection's path
  674. const matchingContexts = pathContexts.filter(ctx =>
  675. ctx.path_prefix.startsWith(col.pwd) || col.pwd.startsWith(ctx.path_prefix)
  676. );
  677. for (const ctx of matchingContexts) {
  678. const displayPath = shortPath(ctx.path_prefix);
  679. console.log(` ${c.dim}context: ${displayPath} → "${ctx.context}"${c.reset}`);
  680. }
  681. }
  682. } else {
  683. console.log(`\n${c.dim}No collections. Run 'qmd add .' to index markdown files.${c.reset}`);
  684. }
  685. db.close();
  686. }
  687. // Update display_paths for all documents that have empty display_path
  688. function updateDisplayPaths(db: Database): number {
  689. // Get all docs with empty display_path, grouped by collection
  690. const emptyDocs = db.prepare(`
  691. SELECT d.id, d.filepath, c.pwd
  692. FROM documents d
  693. JOIN collections c ON d.collection_id = c.id
  694. WHERE d.active = 1 AND (d.display_path IS NULL OR d.display_path = '')
  695. `).all() as { id: number; filepath: string; pwd: string }[];
  696. if (emptyDocs.length === 0) return 0;
  697. // Collect existing display_paths
  698. const existingPaths = new Set<string>(
  699. (db.prepare(`SELECT display_path FROM documents WHERE active = 1 AND display_path != ''`).all() as { display_path: string }[])
  700. .map(r => r.display_path)
  701. );
  702. const updateStmt = db.prepare(`UPDATE documents SET display_path = ? WHERE id = ?`);
  703. let updated = 0;
  704. for (const doc of emptyDocs) {
  705. const displayPath = computeDisplayPath(doc.filepath, doc.pwd, existingPaths);
  706. updateStmt.run(displayPath, doc.id);
  707. existingPaths.add(displayPath);
  708. updated++;
  709. }
  710. return updated;
  711. }
  712. async function updateAllCollections(): Promise<void> {
  713. const db = getDb();
  714. cleanupDuplicateCollections(db);
  715. // Clear Ollama cache on update
  716. clearCache(db);
  717. const collections = db.prepare(`SELECT id, pwd, glob_pattern FROM collections`).all() as { id: number; pwd: string; glob_pattern: string }[];
  718. if (collections.length === 0) {
  719. console.log(`${c.dim}No collections found. Run 'qmd add .' to index markdown files.${c.reset}`);
  720. db.close();
  721. return;
  722. }
  723. // Update display_paths for any documents missing them (migration)
  724. const pathsUpdated = updateDisplayPaths(db);
  725. if (pathsUpdated > 0) {
  726. console.log(`${c.green}✓${c.reset} Updated ${pathsUpdated} display paths`);
  727. }
  728. db.close();
  729. console.log(`${c.bold}Updating ${collections.length} collection(s)...${c.reset}\n`);
  730. for (let i = 0; i < collections.length; i++) {
  731. const col = collections[i];
  732. console.log(`${c.cyan}[${i + 1}/${collections.length}]${c.reset} ${c.bold}${col.pwd}${c.reset}`);
  733. console.log(`${c.dim} Pattern: ${col.glob_pattern}${c.reset}`);
  734. // Temporarily set PWD for indexing
  735. const originalPwd = process.env.PWD;
  736. process.env.PWD = col.pwd;
  737. await indexFiles(col.glob_pattern);
  738. process.env.PWD = originalPwd;
  739. console.log("");
  740. }
  741. console.log(`${c.green}✓ All collections updated.${c.reset}`);
  742. }
  743. async function addContext(pathArg: string, contextText: string): Promise<void> {
  744. const db = getDb();
  745. const now = new Date().toISOString();
  746. // Resolve path - could be relative, absolute, or use ~
  747. let pathPrefix = pathArg;
  748. if (pathPrefix === '.' || pathPrefix === './') {
  749. pathPrefix = getPwd();
  750. } else if (pathPrefix.startsWith('~/')) {
  751. pathPrefix = homedir() + pathPrefix.slice(1);
  752. } else if (!pathPrefix.startsWith('/')) {
  753. pathPrefix = resolve(getPwd(), pathPrefix);
  754. }
  755. // Get realpath and normalize: remove trailing slash
  756. pathPrefix = getRealPath(pathPrefix).replace(/\/$/, '');
  757. // Insert or update
  758. db.prepare(`INSERT INTO path_contexts (path_prefix, context, created_at) VALUES (?, ?, ?)
  759. ON CONFLICT(path_prefix) DO UPDATE SET context = excluded.context`).run(pathPrefix, contextText, now);
  760. console.log(`${c.green}✓${c.reset} Added context for: ${shortPath(pathPrefix)}`);
  761. console.log(`${c.dim}Context: ${contextText}${c.reset}`);
  762. db.close();
  763. }
  764. function getDocument(filename: string, fromLine?: number, maxLines?: number): void {
  765. const db = getDb();
  766. // Parse :linenum suffix from filename (e.g., "file.md:100")
  767. let filepath = filename;
  768. const colonMatch = filepath.match(/:(\d+)$/);
  769. if (colonMatch && !fromLine) {
  770. fromLine = parseInt(colonMatch[1], 10);
  771. filepath = filepath.slice(0, -colonMatch[0].length);
  772. }
  773. // Expand ~ to home directory
  774. if (filepath.startsWith('~/')) {
  775. filepath = homedir() + filepath.slice(1);
  776. }
  777. // Try exact match first
  778. let doc = db.prepare(`SELECT filepath, body FROM documents WHERE filepath = ? AND active = 1`).get(filepath) as { filepath: string; body: string } | null;
  779. // Try matching by filename ending (allows partial paths)
  780. if (!doc) {
  781. doc = db.prepare(`SELECT filepath, body FROM documents WHERE filepath LIKE ? AND active = 1 LIMIT 1`).get(`%${filepath}`) as { filepath: string; body: string } | null;
  782. }
  783. if (!doc) {
  784. console.error(`Document not found: ${filename}`);
  785. db.close();
  786. process.exit(1);
  787. }
  788. // Get context for this file
  789. const context = getContextForFile(db, doc.filepath);
  790. let output = doc.body;
  791. // Apply line filtering if specified
  792. if (fromLine !== undefined || maxLines !== undefined) {
  793. const lines = output.split('\n');
  794. const start = (fromLine || 1) - 1; // Convert to 0-indexed
  795. const end = maxLines !== undefined ? start + maxLines : lines.length;
  796. output = lines.slice(start, end).join('\n');
  797. }
  798. // Output context header if exists
  799. if (context) {
  800. console.log(`Folder Context: ${context}\n---\n`);
  801. }
  802. console.log(output);
  803. db.close();
  804. }
  805. // Get context for a filepath (finds most specific matching path prefix)
  806. function getContextForFile(db: Database, filepath: string): string | null {
  807. // Find all matching prefixes and return the longest (most specific) one
  808. const result = db.prepare(`
  809. SELECT context FROM path_contexts
  810. WHERE ? LIKE path_prefix || '%'
  811. ORDER BY LENGTH(path_prefix) DESC
  812. LIMIT 1
  813. `).get(filepath) as { context: string } | null;
  814. return result?.context || null;
  815. }
  816. async function dropCollection(globPattern: string): Promise<void> {
  817. const db = getDb();
  818. const pwd = getPwd();
  819. const collection = db.prepare(`SELECT id FROM collections WHERE pwd = ? AND glob_pattern = ?`).get(pwd, globPattern) as { id: number } | null;
  820. if (!collection) {
  821. console.log(`No collection found for ${pwd} with pattern ${globPattern}`);
  822. db.close();
  823. return;
  824. }
  825. // Delete documents in this collection
  826. const deleted = db.prepare(`DELETE FROM documents WHERE collection_id = ?`).run(collection.id);
  827. // Delete the collection
  828. db.prepare(`DELETE FROM collections WHERE id = ?`).run(collection.id);
  829. console.log(`Dropped collection: ${pwd} (${globPattern})`);
  830. console.log(`Removed ${deleted.changes} documents`);
  831. console.log(`(Vectors kept for potential reuse)`);
  832. db.close();
  833. }
  834. async function indexFiles(globPattern: string = DEFAULT_GLOB): Promise<void> {
  835. const db = getDb();
  836. const pwd = getPwd();
  837. const now = new Date().toISOString();
  838. const excludeDirs = ["node_modules", ".git", ".cache", "vendor", "dist", "build"];
  839. // Clear Ollama cache on index
  840. clearCache(db);
  841. // Get or create collection for this (pwd, glob)
  842. const collectionId = getOrCreateCollection(db, pwd, globPattern);
  843. console.log(`Collection: ${pwd} (${globPattern})`);
  844. progress.indeterminate();
  845. const glob = new Glob(globPattern);
  846. const files: string[] = [];
  847. for await (const file of glob.scan({ cwd: pwd, onlyFiles: true, followSymlinks: true })) {
  848. // Skip node_modules, hidden folders (.*), and other common excludes
  849. const parts = file.split("/");
  850. const shouldSkip = parts.some(part =>
  851. part === "node_modules" ||
  852. part.startsWith(".") ||
  853. excludeDirs.includes(part)
  854. );
  855. if (!shouldSkip) {
  856. files.push(file);
  857. }
  858. }
  859. const total = files.length;
  860. if (total === 0) {
  861. progress.clear();
  862. console.log("No files found matching pattern.");
  863. db.close();
  864. return;
  865. }
  866. const insertStmt = db.prepare(`INSERT INTO documents (collection_id, name, title, hash, filepath, display_path, body, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`);
  867. const deactivateStmt = db.prepare(`UPDATE documents SET active = 0 WHERE collection_id = ? AND filepath = ? AND active = 1`);
  868. const findActiveStmt = db.prepare(`SELECT id, hash, title, display_path FROM documents WHERE collection_id = ? AND filepath = ? AND active = 1`);
  869. const findActiveAnyCollectionStmt = db.prepare(`SELECT id, collection_id, hash, title, display_path FROM documents WHERE filepath = ? AND active = 1`);
  870. const updateTitleStmt = db.prepare(`UPDATE documents SET title = ?, modified_at = ? WHERE id = ?`);
  871. const updateDisplayPathStmt = db.prepare(`UPDATE documents SET display_path = ? WHERE id = ?`);
  872. // Collect all existing display_paths for uniqueness check
  873. const existingDisplayPaths = new Set<string>(
  874. (db.prepare(`SELECT display_path FROM documents WHERE active = 1 AND display_path != ''`).all() as { display_path: string }[])
  875. .map(r => r.display_path)
  876. );
  877. let indexed = 0, updated = 0, unchanged = 0, processed = 0;
  878. const seenFiles = new Set<string>();
  879. const startTime = Date.now();
  880. for (const relativeFile of files) {
  881. const filepath = getRealPath(resolve(pwd, relativeFile));
  882. seenFiles.add(filepath);
  883. const content = await Bun.file(filepath).text();
  884. const hash = await hashContent(content);
  885. const name = relativeFile.replace(/\.md$/, "").split("/").pop() || relativeFile;
  886. const title = extractTitle(content, relativeFile);
  887. // First check if file exists in THIS collection
  888. const existing = findActiveStmt.get(collectionId, filepath) as { id: number; hash: string; title: string; display_path: string } | null;
  889. if (existing) {
  890. if (existing.hash === hash) {
  891. // Hash unchanged, but check if title needs updating
  892. if (existing.title !== title) {
  893. updateTitleStmt.run(title, now, existing.id);
  894. updated++;
  895. } else {
  896. unchanged++;
  897. }
  898. // Update display_path if empty
  899. if (!existing.display_path) {
  900. const displayPath = computeDisplayPath(filepath, pwd, existingDisplayPaths);
  901. updateDisplayPathStmt.run(displayPath, existing.id);
  902. existingDisplayPaths.add(displayPath);
  903. }
  904. } else {
  905. // Content changed - deactivate old, insert new
  906. existingDisplayPaths.delete(existing.display_path);
  907. deactivateStmt.run(collectionId, filepath);
  908. updated++;
  909. const stat = await Bun.file(filepath).stat();
  910. const displayPath = computeDisplayPath(filepath, pwd, existingDisplayPaths);
  911. insertStmt.run(collectionId, name, title, hash, filepath, displayPath, content, stat ? new Date(stat.birthtime).toISOString() : now, stat ? new Date(stat.mtime).toISOString() : now);
  912. existingDisplayPaths.add(displayPath);
  913. }
  914. } else {
  915. // Check if file exists in ANY collection (would violate unique constraint)
  916. const existingAnywhere = findActiveAnyCollectionStmt.get(filepath) as { id: number; collection_id: number; hash: string; title: string; display_path: string } | null;
  917. if (existingAnywhere) {
  918. // File already indexed in another collection - skip it
  919. unchanged++;
  920. } else {
  921. indexed++;
  922. const stat = await Bun.file(filepath).stat();
  923. const displayPath = computeDisplayPath(filepath, pwd, existingDisplayPaths);
  924. insertStmt.run(collectionId, name, title, hash, filepath, displayPath, content, stat ? new Date(stat.birthtime).toISOString() : now, stat ? new Date(stat.mtime).toISOString() : now);
  925. existingDisplayPaths.add(displayPath);
  926. }
  927. }
  928. processed++;
  929. progress.set((processed / total) * 100);
  930. const elapsed = (Date.now() - startTime) / 1000;
  931. const rate = processed / elapsed;
  932. const remaining = (total - processed) / rate;
  933. const eta = processed > 2 ? ` ETA: ${formatETA(remaining)}` : "";
  934. process.stderr.write(`\rIndexing: ${processed}/${total}${eta} `);
  935. }
  936. // Deactivate documents in this collection that no longer exist
  937. const allActive = db.prepare(`SELECT filepath FROM documents WHERE collection_id = ? AND active = 1`).all(collectionId) as { filepath: string }[];
  938. let removed = 0;
  939. for (const row of allActive) {
  940. if (!seenFiles.has(row.filepath)) {
  941. deactivateStmt.run(collectionId, row.filepath);
  942. removed++;
  943. }
  944. }
  945. // Check if vector index needs updating
  946. const needsEmbedding = getHashesNeedingEmbedding(db);
  947. progress.clear();
  948. console.log(`\nIndexed: ${indexed} new, ${updated} updated, ${unchanged} unchanged, ${removed} removed`);
  949. if (needsEmbedding > 0) {
  950. console.log(`\nRun 'qmd embed' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
  951. }
  952. db.close();
  953. }
  954. function renderProgressBar(percent: number, width: number = 30): string {
  955. const filled = Math.round((percent / 100) * width);
  956. const empty = width - filled;
  957. const bar = "█".repeat(filled) + "░".repeat(empty);
  958. return bar;
  959. }
  960. async function vectorIndex(model: string = DEFAULT_EMBED_MODEL, force: boolean = false): Promise<void> {
  961. const db = getDb();
  962. const now = new Date().toISOString();
  963. // If force, clear all vectors
  964. if (force) {
  965. console.log(`${c.yellow}Force re-indexing: clearing all vectors...${c.reset}`);
  966. db.exec(`DELETE FROM content_vectors`);
  967. db.exec(`DROP TABLE IF EXISTS vectors_vec`);
  968. }
  969. // Find unique hashes that need embedding (from active documents)
  970. // Use MIN(filepath) to get one representative filepath per hash
  971. const hashesToEmbed = db.prepare(`
  972. SELECT d.hash, d.body, MIN(d.filepath) as filepath, MIN(d.display_path) as display_path
  973. FROM documents d
  974. LEFT JOIN content_vectors v ON d.hash = v.hash AND v.seq = 0
  975. WHERE d.active = 1 AND v.hash IS NULL
  976. GROUP BY d.hash
  977. `).all() as { hash: string; body: string; filepath: string; display_path: string }[];
  978. if (hashesToEmbed.length === 0) {
  979. console.log(`${c.green}✓ All content hashes already have embeddings.${c.reset}`);
  980. db.close();
  981. return;
  982. }
  983. // Prepare documents with chunks
  984. type ChunkItem = { hash: string; title: string; text: string; seq: number; pos: number; bytes: number; displayName: string };
  985. const allChunks: ChunkItem[] = [];
  986. let multiChunkDocs = 0;
  987. for (const item of hashesToEmbed) {
  988. const encoder = new TextEncoder();
  989. const bodyBytes = encoder.encode(item.body).length;
  990. if (bodyBytes === 0) continue; // Skip empty
  991. const title = extractTitle(item.body, item.filepath);
  992. const displayName = item.display_path || item.filepath;
  993. const chunks = chunkDocument(item.body, CHUNK_BYTE_SIZE);
  994. if (chunks.length > 1) multiChunkDocs++;
  995. for (let seq = 0; seq < chunks.length; seq++) {
  996. allChunks.push({
  997. hash: item.hash,
  998. title,
  999. text: chunks[seq].text,
  1000. seq,
  1001. pos: chunks[seq].pos,
  1002. bytes: encoder.encode(chunks[seq].text).length,
  1003. displayName,
  1004. });
  1005. }
  1006. }
  1007. if (allChunks.length === 0) {
  1008. console.log(`${c.green}✓ No non-empty documents to embed.${c.reset}`);
  1009. db.close();
  1010. return;
  1011. }
  1012. const totalBytes = allChunks.reduce((sum, c) => sum + c.bytes, 0);
  1013. const totalChunks = allChunks.length;
  1014. const totalDocs = hashesToEmbed.length;
  1015. console.log(`${c.bold}Embedding ${totalDocs} documents${c.reset} ${c.dim}(${totalChunks} chunks, ${formatBytes(totalBytes)})${c.reset}`);
  1016. if (multiChunkDocs > 0) {
  1017. console.log(`${c.dim}${multiChunkDocs} documents split into multiple chunks${c.reset}`);
  1018. }
  1019. console.log(`${c.dim}Model: ${model}${c.reset}\n`);
  1020. // Hide cursor during embedding
  1021. cursor.hide();
  1022. // Get embedding dimensions from first chunk
  1023. progress.indeterminate();
  1024. const firstEmbedding = await getEmbedding(allChunks[0].text, model, false, allChunks[0].title);
  1025. ensureVecTable(db, firstEmbedding.length);
  1026. const insertVecStmt = db.prepare(`INSERT OR REPLACE INTO vectors_vec (hash_seq, embedding) VALUES (?, ?)`);
  1027. const insertContentVectorStmt = db.prepare(`INSERT OR REPLACE INTO content_vectors (hash, seq, pos, model, embedded_at) VALUES (?, ?, ?, ?, ?)`);
  1028. let chunksEmbedded = 0, errors = 0, bytesProcessed = 0;
  1029. const startTime = Date.now();
  1030. // Insert first chunk
  1031. const firstHashSeq = `${allChunks[0].hash}_${allChunks[0].seq}`;
  1032. insertVecStmt.run(firstHashSeq, new Float32Array(firstEmbedding));
  1033. insertContentVectorStmt.run(allChunks[0].hash, allChunks[0].seq, allChunks[0].pos, model, now);
  1034. chunksEmbedded++;
  1035. bytesProcessed += allChunks[0].bytes;
  1036. for (let i = 1; i < allChunks.length; i++) {
  1037. const chunk = allChunks[i];
  1038. try {
  1039. const embedding = await getEmbedding(chunk.text, model, false, chunk.title);
  1040. const hashSeq = `${chunk.hash}_${chunk.seq}`;
  1041. insertVecStmt.run(hashSeq, new Float32Array(embedding));
  1042. insertContentVectorStmt.run(chunk.hash, chunk.seq, chunk.pos, model, now);
  1043. chunksEmbedded++;
  1044. bytesProcessed += chunk.bytes;
  1045. } catch (err) {
  1046. errors++;
  1047. bytesProcessed += chunk.bytes;
  1048. progress.error();
  1049. console.error(`\n${c.yellow}⚠ Error embedding "${chunk.displayName}" chunk ${chunk.seq}: ${err}${c.reset}`);
  1050. }
  1051. const percent = (bytesProcessed / totalBytes) * 100;
  1052. progress.set(percent);
  1053. const elapsed = (Date.now() - startTime) / 1000;
  1054. const bytesPerSec = bytesProcessed / elapsed;
  1055. const remainingBytes = totalBytes - bytesProcessed;
  1056. const etaSec = remainingBytes / bytesPerSec;
  1057. const bar = renderProgressBar(percent);
  1058. const percentStr = percent.toFixed(0).padStart(3);
  1059. const throughput = `${formatBytes(bytesPerSec)}/s`;
  1060. const eta = elapsed > 2 ? formatETA(etaSec) : "...";
  1061. const errStr = errors > 0 ? ` ${c.yellow}${errors} err${c.reset}` : "";
  1062. 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} `);
  1063. }
  1064. progress.clear();
  1065. cursor.show();
  1066. const totalTimeSec = (Date.now() - startTime) / 1000;
  1067. const avgThroughput = formatBytes(totalBytes / totalTimeSec);
  1068. console.log(`\r${c.green}${renderProgressBar(100)}${c.reset} ${c.bold}100%${c.reset} `);
  1069. 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}`);
  1070. if (errors > 0) {
  1071. console.log(`${c.yellow}⚠ ${errors} chunks failed${c.reset}`);
  1072. }
  1073. db.close();
  1074. }
  1075. function escapeCSV(value: string): string {
  1076. if (value.includes('"') || value.includes(',') || value.includes('\n')) {
  1077. return `"${value.replace(/"/g, '""')}"`;
  1078. }
  1079. return value;
  1080. }
  1081. function extractSnippet(body: string, query: string, maxLen = 500, chunkPos?: number): { line: number; snippet: string } {
  1082. // If chunkPos provided, calculate line offset and focus search there
  1083. let lineOffset = 0;
  1084. let searchBody = body;
  1085. if (chunkPos && chunkPos > 0) {
  1086. // Count lines before chunkPos to get line offset
  1087. const beforeChunk = body.slice(0, chunkPos);
  1088. lineOffset = beforeChunk.split('\n').length - 1;
  1089. // Focus search on the chunk area (with some context before)
  1090. const contextStart = Math.max(0, chunkPos - 200);
  1091. searchBody = body.slice(contextStart);
  1092. if (contextStart > 0) {
  1093. lineOffset = body.slice(0, contextStart).split('\n').length - 1;
  1094. }
  1095. }
  1096. const lines = searchBody.split('\n');
  1097. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
  1098. let bestLine = 0, bestScore = -1;
  1099. for (let i = 0; i < lines.length; i++) {
  1100. const lineLower = lines[i].toLowerCase();
  1101. let score = 0;
  1102. for (const term of queryTerms) {
  1103. if (lineLower.includes(term)) score++;
  1104. }
  1105. if (score > bestScore) {
  1106. bestScore = score;
  1107. bestLine = i;
  1108. }
  1109. }
  1110. const startLine = Math.max(0, bestLine - 1);
  1111. const endLine = Math.min(lines.length, bestLine + 2);
  1112. let snippet = lines.slice(startLine, endLine).join('\n');
  1113. if (snippet.length > maxLen) snippet = snippet.substring(0, maxLen - 3) + "...";
  1114. return { line: lineOffset + bestLine + 1, snippet };
  1115. }
  1116. type SearchResult = { file: string; displayPath: string; title: string; body: string; score: number; source: "fts" | "vec"; chunkPos?: number };
  1117. // Sanitize a term for FTS5: remove punctuation except apostrophes
  1118. function sanitizeFTS5Term(term: string): string {
  1119. // Remove all non-alphanumeric except apostrophes (for contractions like "don't")
  1120. return term.replace(/[^\w']/g, '').trim();
  1121. }
  1122. // Build FTS5 query: phrase-aware with fallback to individual terms
  1123. function buildFTS5Query(query: string): string {
  1124. // Sanitize the full query for phrase matching
  1125. const sanitizedQuery = query.replace(/[^\w\s']/g, '').trim();
  1126. const terms = query
  1127. .split(/\s+/)
  1128. .map(sanitizeFTS5Term)
  1129. .filter(term => term.length >= 2); // Skip single chars and empty
  1130. if (terms.length === 0) return "";
  1131. if (terms.length === 1) return `"${terms[0].replace(/"/g, '""')}"`;
  1132. // Strategy: exact phrase OR proximity match OR individual terms
  1133. // Exact phrase matches rank highest, then close proximity, then any term
  1134. const phrase = `"${sanitizedQuery.replace(/"/g, '""')}"`;
  1135. const quotedTerms = terms.map(t => `"${t.replace(/"/g, '""')}"`);
  1136. // FTS5 NEAR syntax: NEAR(term1 term2, distance)
  1137. const nearPhrase = `NEAR(${quotedTerms.join(' ')}, 10)`;
  1138. const orTerms = quotedTerms.join(' OR ');
  1139. // Exact phrase > proximity > any term
  1140. return `(${phrase}) OR (${nearPhrase}) OR (${orTerms})`;
  1141. }
  1142. // Normalize BM25 score to 0-1 range using sigmoid
  1143. function normalizeBM25(score: number): number {
  1144. // BM25 scores are negative in SQLite (lower = better)
  1145. // Typical range: -15 (excellent) to -2 (weak match)
  1146. // Map to 0-1 where higher is better
  1147. const absScore = Math.abs(score);
  1148. // Sigmoid-ish normalization: maps ~2-15 range to ~0.1-0.95
  1149. return 1 / (1 + Math.exp(-(absScore - 5) / 3));
  1150. }
  1151. function searchFTS(db: Database, query: string, limit: number = 20): SearchResult[] {
  1152. const ftsQuery = buildFTS5Query(query);
  1153. if (!ftsQuery) return [];
  1154. // BM25 weights: name=10, body=1 (title matches ranked higher)
  1155. const stmt = db.prepare(`
  1156. SELECT d.filepath, d.display_path, d.title, d.body, bm25(documents_fts, 10.0, 1.0) as score
  1157. FROM documents_fts f
  1158. JOIN documents d ON d.id = f.rowid
  1159. WHERE documents_fts MATCH ? AND d.active = 1
  1160. ORDER BY score
  1161. LIMIT ?
  1162. `);
  1163. const results = stmt.all(ftsQuery, limit) as { filepath: string; display_path: string; title: string; body: string; score: number }[];
  1164. return results.map(r => ({
  1165. file: r.filepath,
  1166. displayPath: r.display_path,
  1167. title: r.title,
  1168. body: r.body,
  1169. score: normalizeBM25(r.score),
  1170. source: "fts" as const,
  1171. }));
  1172. }
  1173. async function searchVec(db: Database, query: string, model: string, limit: number = 20): Promise<SearchResult[]> {
  1174. const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  1175. if (!tableExists) return [];
  1176. const queryEmbedding = await getEmbedding(query, model, true);
  1177. const queryVec = new Float32Array(queryEmbedding);
  1178. // Join: vectors_vec -> content_vectors -> documents
  1179. // Over-retrieve to handle multiple chunks per document, then dedupe
  1180. const stmt = db.prepare(`
  1181. SELECT d.filepath, d.display_path, d.title, d.body, vec.distance, cv.pos
  1182. FROM vectors_vec vec
  1183. JOIN content_vectors cv ON vec.hash_seq = cv.hash || '_' || cv.seq
  1184. JOIN documents d ON d.hash = cv.hash AND d.active = 1
  1185. WHERE vec.embedding MATCH ? AND k = ?
  1186. ORDER BY vec.distance
  1187. `);
  1188. const rawResults = stmt.all(queryVec, limit * 3) as { filepath: string; display_path: string; title: string; body: string; distance: number; pos: number }[];
  1189. // Aggregate chunks per document: max score + small bonus for additional matches
  1190. const byFile = new Map<string, { filepath: string; displayPath: string; title: string; body: string; chunkCount: number; bestPos: number; bestDist: number }>();
  1191. for (const r of rawResults) {
  1192. const existing = byFile.get(r.filepath);
  1193. if (!existing) {
  1194. 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 });
  1195. } else {
  1196. existing.chunkCount++;
  1197. if (r.distance < existing.bestDist) {
  1198. existing.bestDist = r.distance;
  1199. existing.bestPos = r.pos;
  1200. }
  1201. }
  1202. }
  1203. // Score = max chunk score + 0.02 bonus per additional chunk (capped at +0.1)
  1204. return Array.from(byFile.values())
  1205. .map(r => {
  1206. const maxScore = 1 / (1 + r.bestDist);
  1207. const bonusChunks = Math.min(r.chunkCount - 1, 5);
  1208. const bonus = bonusChunks * 0.02;
  1209. return {
  1210. file: r.filepath,
  1211. displayPath: r.displayPath,
  1212. title: r.title,
  1213. body: r.body,
  1214. score: maxScore + bonus,
  1215. source: "vec" as const,
  1216. chunkPos: r.bestPos,
  1217. };
  1218. })
  1219. .sort((a, b) => b.score - a.score)
  1220. .slice(0, limit);
  1221. }
  1222. function normalizeScores(results: SearchResult[]): SearchResult[] {
  1223. if (results.length === 0) return results;
  1224. const maxScore = Math.max(...results.map(r => r.score));
  1225. const minScore = Math.min(...results.map(r => r.score));
  1226. const range = maxScore - minScore || 1;
  1227. return results.map(r => ({ ...r, score: (r.score - minScore) / range }));
  1228. }
  1229. // Reciprocal Rank Fusion: combines multiple ranked lists
  1230. // RRF score = sum(1 / (k + rank)) across all lists where doc appears
  1231. // k=60 is standard, provides good balance between top and lower ranks
  1232. type RankedResult = { file: string; displayPath: string; title: string; body: string; score: number };
  1233. function reciprocalRankFusion(
  1234. resultLists: RankedResult[][],
  1235. weights: number[] = [], // Weight per result list (default 1.0)
  1236. k: number = 60
  1237. ): RankedResult[] {
  1238. const scores = new Map<string, { score: number; displayPath: string; title: string; body: string; bestRank: number }>();
  1239. for (let listIdx = 0; listIdx < resultLists.length; listIdx++) {
  1240. const results = resultLists[listIdx];
  1241. const weight = weights[listIdx] ?? 1.0;
  1242. for (let rank = 0; rank < results.length; rank++) {
  1243. const doc = results[rank];
  1244. const rrfScore = weight / (k + rank + 1);
  1245. const existing = scores.get(doc.file);
  1246. if (existing) {
  1247. existing.score += rrfScore;
  1248. existing.bestRank = Math.min(existing.bestRank, rank);
  1249. } else {
  1250. scores.set(doc.file, { score: rrfScore, displayPath: doc.displayPath, title: doc.title, body: doc.body, bestRank: rank });
  1251. }
  1252. }
  1253. }
  1254. // Add bonus for best rank: documents that ranked #1-3 in any list get a boost
  1255. // This prevents dilution of exact matches by expansion queries
  1256. return Array.from(scores.entries())
  1257. .map(([file, { score, displayPath, title, body, bestRank }]) => {
  1258. let bonus = 0;
  1259. if (bestRank === 0) bonus = 0.05; // Ranked #1 somewhere
  1260. else if (bestRank <= 2) bonus = 0.02; // Ranked top-3 somewhere
  1261. return { file, displayPath, title, body, score: score + bonus };
  1262. })
  1263. .sort((a, b) => b.score - a.score);
  1264. }
  1265. type OutputFormat = "cli" | "csv" | "md" | "xml" | "files" | "json";
  1266. type OutputOptions = {
  1267. format: OutputFormat;
  1268. full: boolean;
  1269. limit: number;
  1270. minScore: number;
  1271. all?: boolean;
  1272. };
  1273. // Extract snippet with more context lines for CLI display
  1274. function extractSnippetWithContext(body: string, query: string, contextLines = 3, chunkPos?: number): { line: number; snippet: string; hasMatch: boolean } {
  1275. // If chunkPos provided, focus search on that area
  1276. let lineOffset = 0;
  1277. let searchBody = body;
  1278. if (chunkPos && chunkPos > 0) {
  1279. const contextStart = Math.max(0, chunkPos - 200);
  1280. searchBody = body.slice(contextStart);
  1281. if (contextStart > 0) {
  1282. lineOffset = body.slice(0, contextStart).split('\n').length - 1;
  1283. }
  1284. }
  1285. const lines = searchBody.split('\n');
  1286. const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0);
  1287. let bestLine = 0, bestScore = -1;
  1288. for (let i = 0; i < lines.length; i++) {
  1289. const lineLower = lines[i].toLowerCase();
  1290. let score = 0;
  1291. for (const term of queryTerms) {
  1292. if (lineLower.includes(term)) score++;
  1293. }
  1294. if (score > bestScore) {
  1295. bestScore = score;
  1296. bestLine = i;
  1297. }
  1298. }
  1299. // No query match found - return beginning of chunk area or file
  1300. if (bestScore <= 0) {
  1301. const preview = lines.slice(0, contextLines * 2).join('\n').trim();
  1302. return { line: lineOffset + 1, snippet: preview, hasMatch: false };
  1303. }
  1304. const startLine = Math.max(0, bestLine - contextLines);
  1305. const endLine = Math.min(lines.length, bestLine + contextLines + 1);
  1306. const snippet = lines.slice(startLine, endLine).join('\n').trim();
  1307. return { line: lineOffset + bestLine + 1, snippet, hasMatch: true };
  1308. }
  1309. // Highlight query terms in text (skip short words < 3 chars)
  1310. function highlightTerms(text: string, query: string): string {
  1311. if (!useColor) return text;
  1312. const terms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  1313. let result = text;
  1314. for (const term of terms) {
  1315. const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
  1316. result = result.replace(regex, `${c.yellow}${c.bold}$1${c.reset}`);
  1317. }
  1318. return result;
  1319. }
  1320. // Format score with color based on value
  1321. function formatScore(score: number): string {
  1322. const pct = (score * 100).toFixed(0).padStart(3);
  1323. if (!useColor) return `${pct}%`;
  1324. if (score >= 0.7) return `${c.green}${pct}%${c.reset}`;
  1325. if (score >= 0.4) return `${c.yellow}${pct}%${c.reset}`;
  1326. return `${c.dim}${pct}%${c.reset}`;
  1327. }
  1328. // Shorten directory path for display - relative to $HOME (used for context paths, not documents)
  1329. function shortPath(dirpath: string): string {
  1330. const home = homedir();
  1331. if (dirpath.startsWith(home)) {
  1332. return '~' + dirpath.slice(home.length);
  1333. }
  1334. return dirpath;
  1335. }
  1336. function outputResults(results: { file: string; displayPath: string; title: string; body: string; score: number; context?: string | null; chunkPos?: number }[], query: string, opts: OutputOptions): void {
  1337. const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit);
  1338. if (filtered.length === 0) {
  1339. console.log("No results found above minimum score threshold.");
  1340. return;
  1341. }
  1342. if (opts.format === "json") {
  1343. // JSON output for LLM consumption
  1344. const output = filtered.map(row => ({
  1345. score: Math.round(row.score * 100) / 100,
  1346. file: row.displayPath,
  1347. title: row.title,
  1348. ...(row.context && { context: row.context }),
  1349. ...(opts.full && { body: row.body }),
  1350. ...(!opts.full && { snippet: extractSnippet(row.body, query, 300, row.chunkPos).snippet }),
  1351. }));
  1352. console.log(JSON.stringify(output, null, 2));
  1353. } else if (opts.format === "files") {
  1354. // Simple score,filepath,context output
  1355. for (const row of filtered) {
  1356. const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
  1357. console.log(`${row.score.toFixed(2)},${row.displayPath}${ctx}`);
  1358. }
  1359. } else if (opts.format === "cli") {
  1360. for (let i = 0; i < filtered.length; i++) {
  1361. const row = filtered[i];
  1362. const { line, snippet, hasMatch } = extractSnippetWithContext(row.body, query, 2, row.chunkPos);
  1363. // Line 1: filepath
  1364. const path = row.displayPath;
  1365. const lineInfo = hasMatch ? `:${line}` : "";
  1366. console.log(`${c.cyan}${path}${c.dim}${lineInfo}${c.reset}`);
  1367. // Line 2: Title (if available)
  1368. if (row.title) {
  1369. console.log(`${c.bold}Title: ${row.title}${c.reset}`);
  1370. }
  1371. // Line 3: Context (if available)
  1372. if (row.context) {
  1373. console.log(`${c.dim}Context: ${row.context}${c.reset}`);
  1374. }
  1375. // Line 4: Score
  1376. const score = formatScore(row.score);
  1377. console.log(`Score: ${c.bold}${score}${c.reset}`);
  1378. console.log();
  1379. // Snippet with highlighting (no leading | chars for better word wrap)
  1380. const highlighted = highlightTerms(snippet, query);
  1381. console.log(highlighted);
  1382. // Double empty line between results
  1383. if (i < filtered.length - 1) console.log('\n');
  1384. }
  1385. } else if (opts.format === "md") {
  1386. for (const row of filtered) {
  1387. const heading = row.title || row.displayPath;
  1388. if (opts.full) {
  1389. console.log(`---\n# ${heading}\n\n${row.body}\n`);
  1390. } else {
  1391. const { snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
  1392. console.log(`---\n# ${heading}\n\n${snippet}\n`);
  1393. }
  1394. }
  1395. } else if (opts.format === "xml") {
  1396. for (const row of filtered) {
  1397. const titleAttr = row.title ? ` title="${row.title.replace(/"/g, '&quot;')}"` : "";
  1398. if (opts.full) {
  1399. console.log(`<file name="${row.displayPath}"${titleAttr}>\n${row.body}\n</file>\n`);
  1400. } else {
  1401. const { snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
  1402. console.log(`<file name="${row.displayPath}"${titleAttr}>\n${snippet}\n</file>\n`);
  1403. }
  1404. }
  1405. } else {
  1406. // CSV format
  1407. console.log("score,file,title,line,snippet");
  1408. for (const row of filtered) {
  1409. const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos);
  1410. const content = opts.full ? row.body : snippet;
  1411. console.log(`${row.score.toFixed(4)},${escapeCSV(row.displayPath)},${escapeCSV(row.title)},${line},${escapeCSV(content)}`);
  1412. }
  1413. }
  1414. }
  1415. function search(query: string, opts: OutputOptions): void {
  1416. const db = getDb();
  1417. // Use large limit for --all, otherwise fetch more than needed and let outputResults filter
  1418. const fetchLimit = opts.all ? 100000 : Math.max(50, opts.limit * 2);
  1419. const results = searchFTS(db, query, fetchLimit);
  1420. // Add context to results
  1421. const resultsWithContext = results.map(r => ({
  1422. ...r,
  1423. context: getContextForFile(db, r.file),
  1424. }));
  1425. db.close();
  1426. if (resultsWithContext.length === 0) {
  1427. console.log("No results found.");
  1428. return;
  1429. }
  1430. outputResults(resultsWithContext, query, opts);
  1431. }
  1432. async function vectorSearch(query: string, opts: OutputOptions, model: string = DEFAULT_EMBED_MODEL): Promise<void> {
  1433. const db = getDb();
  1434. const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  1435. if (!tableExists) {
  1436. console.error("Vector index not found. Run 'qmd embed' first to create embeddings.");
  1437. db.close();
  1438. return;
  1439. }
  1440. // Check index health and warn about issues
  1441. checkIndexHealth(db);
  1442. // Expand query to multiple variations (with caching)
  1443. const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, db);
  1444. process.stderr.write(`Searching with ${queries.length} query variations...\n`);
  1445. // Collect results from all query variations
  1446. // For --all, fetch more results per query
  1447. const perQueryLimit = opts.all ? 500 : 20;
  1448. const allResults = new Map<string, { file: string; displayPath: string; title: string; body: string; score: number }>();
  1449. for (const q of queries) {
  1450. const vecResults = await searchVec(db, q, model, perQueryLimit);
  1451. for (const r of vecResults) {
  1452. const existing = allResults.get(r.file);
  1453. if (!existing || r.score > existing.score) {
  1454. allResults.set(r.file, { file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score });
  1455. }
  1456. }
  1457. }
  1458. // Sort by max score and limit to requested count
  1459. const results = Array.from(allResults.values())
  1460. .sort((a, b) => b.score - a.score)
  1461. .slice(0, opts.limit)
  1462. .map(r => ({ ...r, context: getContextForFile(db, r.file) }));
  1463. db.close();
  1464. if (results.length === 0) {
  1465. console.log("No results found.");
  1466. return;
  1467. }
  1468. outputResults(results, query, { ...opts, limit: results.length }); // Already limited
  1469. }
  1470. async function expandQuery(query: string, model: string = DEFAULT_QUERY_MODEL, db?: Database): Promise<string[]> {
  1471. process.stderr.write("Generating query variations...\n");
  1472. const prompt = `You are a search query expander. Given a search query, generate 2 alternative queries that would help find relevant documents.
  1473. Rules:
  1474. - Use synonyms and related terminology (e.g., "craft" → "craftsmanship", "quality", "excellence")
  1475. - Rephrase to capture different angles (e.g., "engineering culture" → "technical excellence", "developer practices")
  1476. - Keep proper nouns and named concepts exactly as written (e.g., "Build a Business", "Stripe", "Shopify")
  1477. - Each variation should be 3-8 words, natural search terms
  1478. - Do NOT just append words like "search" or "find" or "documents"
  1479. Query: "${query}"
  1480. Output exactly 2 variations, one per line, no numbering or bullets:`;
  1481. const requestBody = {
  1482. model,
  1483. prompt,
  1484. stream: false,
  1485. think: false,
  1486. options: { num_predict: 150 },
  1487. };
  1488. // Check cache
  1489. const cacheDb = db || getDb();
  1490. const cacheKey = getCacheKey(`${OLLAMA_URL}/api/generate`, requestBody);
  1491. const cached = getCachedResult(cacheDb, cacheKey);
  1492. let responseText: string;
  1493. if (cached) {
  1494. responseText = cached;
  1495. } else {
  1496. const response = await fetch(`${OLLAMA_URL}/api/generate`, {
  1497. method: "POST",
  1498. headers: { "Content-Type": "application/json" },
  1499. body: JSON.stringify(requestBody),
  1500. });
  1501. if (!response.ok) {
  1502. const errorText = await response.text();
  1503. if (errorText.includes("not found") || errorText.includes("does not exist")) {
  1504. await ensureModelAvailable(model);
  1505. if (!db) cacheDb.close();
  1506. return expandQuery(query, model, db);
  1507. }
  1508. if (!db) cacheDb.close();
  1509. return [query];
  1510. }
  1511. const data = await response.json() as { response: string };
  1512. responseText = data.response;
  1513. setCachedResult(cacheDb, cacheKey, responseText);
  1514. }
  1515. if (!db) cacheDb.close();
  1516. const lines = responseText.trim().split('\n')
  1517. .map(l => l.replace(/^[\d\.\-\*\"\s]+/, '').replace(/["\s]+$/, '').trim())
  1518. .filter(l => l.length > 2 && l.length < 100 && !l.startsWith('<') && !l.toLowerCase().includes('variation'))
  1519. .slice(0, 2);
  1520. const allQueries = [query, ...lines];
  1521. process.stderr.write(`${c.dim}Queries: ${allQueries.join(' | ')}${c.reset}\n`);
  1522. return allQueries;
  1523. }
  1524. async function querySearch(query: string, opts: OutputOptions, embedModel: string = DEFAULT_EMBED_MODEL, rerankModel: string = DEFAULT_RERANK_MODEL): Promise<void> {
  1525. const db = getDb();
  1526. // Check index health and warn about issues
  1527. checkIndexHealth(db);
  1528. // Expand query to multiple variations (with caching)
  1529. const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, db);
  1530. process.stderr.write(`Searching with ${queries.length} query variations...\n`);
  1531. // Collect ranked result lists for RRF fusion
  1532. const rankedLists: RankedResult[][] = [];
  1533. const hasVectors = !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  1534. for (const q of queries) {
  1535. // FTS search - get ranked results
  1536. const ftsResults = searchFTS(db, q, 20);
  1537. if (ftsResults.length > 0) {
  1538. rankedLists.push(ftsResults.map(r => ({ file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score })));
  1539. }
  1540. // Vector search - get ranked results
  1541. if (hasVectors) {
  1542. const vecResults = await searchVec(db, q, embedModel, 20);
  1543. if (vecResults.length > 0) {
  1544. rankedLists.push(vecResults.map(r => ({ file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score })));
  1545. }
  1546. }
  1547. }
  1548. // Apply Reciprocal Rank Fusion to combine all ranked lists
  1549. // Give 2x weight to original query results (first 2 lists: FTS + vector)
  1550. const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
  1551. const fused = reciprocalRankFusion(rankedLists, weights);
  1552. const candidates = fused.slice(0, 30); // Over-retrieve for reranking
  1553. if (candidates.length === 0) {
  1554. console.log("No results found.");
  1555. db.close();
  1556. return;
  1557. }
  1558. // Rerank with the original query (with caching)
  1559. const reranked = await rerank(
  1560. query,
  1561. candidates.map(c => ({ file: c.file, text: c.body })),
  1562. rerankModel,
  1563. db
  1564. );
  1565. // Blend RRF position score with reranker score using position-aware weights
  1566. // Top retrieval results get more protection from reranker disagreement
  1567. const candidateMap = new Map(candidates.map(c => [c.file, { displayPath: c.displayPath, title: c.title, body: c.body }]));
  1568. const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1])); // 1-indexed rank
  1569. const finalResults = reranked.map(r => {
  1570. const rrfRank = rrfRankMap.get(r.file) || 30;
  1571. // Position-aware blending: top retrieval results preserved more
  1572. // Rank 1-3: 75% RRF, 25% reranker (trust retrieval for exact matches)
  1573. // Rank 4-10: 60% RRF, 40% reranker
  1574. // Rank 11+: 40% RRF, 60% reranker (trust reranker for lower-ranked)
  1575. let rrfWeight: number;
  1576. if (rrfRank <= 3) {
  1577. rrfWeight = 0.75;
  1578. } else if (rrfRank <= 10) {
  1579. rrfWeight = 0.60;
  1580. } else {
  1581. rrfWeight = 0.40;
  1582. }
  1583. const rrfScore = 1 / rrfRank; // Position-based: 1, 0.5, 0.33...
  1584. const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
  1585. const candidate = candidateMap.get(r.file);
  1586. return {
  1587. file: r.file,
  1588. displayPath: candidate?.displayPath || "",
  1589. title: candidate?.title || "",
  1590. body: candidate?.body || "",
  1591. score: blendedScore,
  1592. context: getContextForFile(db, r.file),
  1593. };
  1594. }).sort((a, b) => b.score - a.score);
  1595. db.close();
  1596. outputResults(finalResults, query, opts);
  1597. }
  1598. // =============================================================================
  1599. // MCP Server Implementation
  1600. // =============================================================================
  1601. // Convert search results to CSV format for MCP responses
  1602. function toMcpCsv(results: { file: string; title: string; score: number; context: string | null; snippet: string }[]): string {
  1603. const escapeField = (val: string | null | number): string => {
  1604. if (val === null || val === undefined) return "";
  1605. const str = String(val);
  1606. if (str.includes(",") || str.includes('"') || str.includes("\n")) {
  1607. return `"${str.replace(/"/g, '""')}"`;
  1608. }
  1609. return str;
  1610. };
  1611. const header = "file,title,score,context,snippet";
  1612. const rows = results.map(r =>
  1613. [r.file, r.title, r.score, r.context || "", r.snippet].map(escapeField).join(",")
  1614. );
  1615. return [header, ...rows].join("\n");
  1616. }
  1617. async function startMcpServer(): Promise<void> {
  1618. const server = new McpServer({
  1619. name: "qmd",
  1620. version: "1.0.0",
  1621. });
  1622. // Register the query prompt - describes ideal usage
  1623. server.registerPrompt(
  1624. "query",
  1625. {
  1626. title: "QMD Query Guide",
  1627. description: "How to effectively search your knowledge base with QMD",
  1628. },
  1629. () => ({
  1630. messages: [
  1631. {
  1632. role: "user",
  1633. content: {
  1634. type: "text",
  1635. text: `# QMD - Quick Markdown Search
  1636. QMD is your on-device search engine for markdown knowledge bases. Use it to find information across your notes, documents, and meeting transcripts.
  1637. ## Available Tools
  1638. ### 1. qmd_search (Fast keyword search)
  1639. Best for: Finding documents with specific keywords or phrases.
  1640. - Uses BM25 full-text search
  1641. - Fast, no LLM required
  1642. - Good for exact matches
  1643. ### 2. qmd_vsearch (Semantic search)
  1644. Best for: Finding conceptually related content even without exact keyword matches.
  1645. - Uses vector embeddings
  1646. - Understands meaning and context
  1647. - Good for "how do I..." or conceptual queries
  1648. ### 3. qmd_query (Hybrid search - highest quality)
  1649. Best for: Important searches where you want the best results.
  1650. - Combines keyword + semantic search
  1651. - Expands your query with variations
  1652. - Re-ranks results with LLM
  1653. - Slower but most accurate
  1654. ### 4. qmd_get (Retrieve document)
  1655. Best for: Getting the full content of a document you found.
  1656. - Use the file path from search results
  1657. - Supports line ranges: \`file.md:100\` or \`--from 50 -l 20\`
  1658. ## Search Strategy
  1659. 1. **Start with qmd_search** for quick keyword lookups
  1660. 2. **Use qmd_vsearch** when keywords aren't working or for conceptual queries
  1661. 3. **Use qmd_query** for important searches or when you need high confidence
  1662. 4. **Use qmd_get** to retrieve full documents after finding them
  1663. ## Tips
  1664. - Use \`--min-score 0.5\` to filter low-relevance results
  1665. - Use \`--all --files\` to get a complete list of matches
  1666. - Check the "Context" field - it describes what kind of content the file contains
  1667. - File paths are relative to their collection (e.g., \`pages/meeting.md\`)`,
  1668. },
  1669. },
  1670. ],
  1671. })
  1672. );
  1673. // Tool: search (BM25 full-text)
  1674. server.registerTool(
  1675. "qmd_search",
  1676. {
  1677. title: "Search (BM25)",
  1678. description: "Fast keyword-based full-text search using BM25. Best for finding documents with specific words or phrases.",
  1679. inputSchema: {
  1680. query: z.string().describe("Search query - keywords or phrases to find"),
  1681. limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
  1682. minScore: z.number().optional().default(0).describe("Minimum relevance score 0-1 (default: 0)"),
  1683. },
  1684. },
  1685. async ({ query, limit, minScore }) => {
  1686. const db = getDb();
  1687. const results = searchFTS(db, query, limit || 10);
  1688. const filtered = results
  1689. .filter(r => r.score >= (minScore || 0))
  1690. .map(r => ({
  1691. file: r.displayPath,
  1692. title: r.title,
  1693. score: Math.round(r.score * 100) / 100,
  1694. context: getContextForFile(db, r.file),
  1695. snippet: extractSnippet(r.body, query, 300, r.chunkPos).snippet,
  1696. }));
  1697. db.close();
  1698. return {
  1699. content: [
  1700. {
  1701. type: "text",
  1702. mimeType: "text/csv",
  1703. text: toMcpCsv(filtered),
  1704. },
  1705. ],
  1706. };
  1707. }
  1708. );
  1709. // Tool: vsearch (Vector semantic search)
  1710. server.registerTool(
  1711. "qmd_vsearch",
  1712. {
  1713. title: "Vector Search (Semantic)",
  1714. description: "Semantic similarity search using vector embeddings. Finds conceptually related content even without exact keyword matches. Requires embeddings (run 'qmd embed' first).",
  1715. inputSchema: {
  1716. query: z.string().describe("Natural language query - describe what you're looking for"),
  1717. limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
  1718. minScore: z.number().optional().default(0.3).describe("Minimum relevance score 0-1 (default: 0.3)"),
  1719. },
  1720. },
  1721. async ({ query, limit, minScore }) => {
  1722. const db = getDb();
  1723. const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  1724. if (!tableExists) {
  1725. db.close();
  1726. return {
  1727. content: [{ type: "text", text: "Error: Vector index not found. Run 'qmd embed' first to create embeddings." }],
  1728. };
  1729. }
  1730. // Expand query
  1731. const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, db);
  1732. // Collect results
  1733. const allResults = new Map<string, { file: string; displayPath: string; title: string; body: string; score: number }>();
  1734. for (const q of queries) {
  1735. const vecResults = await searchVec(db, q, DEFAULT_EMBED_MODEL, limit || 10);
  1736. for (const r of vecResults) {
  1737. const existing = allResults.get(r.file);
  1738. if (!existing || r.score > existing.score) {
  1739. allResults.set(r.file, { file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score });
  1740. }
  1741. }
  1742. }
  1743. const filtered = Array.from(allResults.values())
  1744. .sort((a, b) => b.score - a.score)
  1745. .slice(0, limit || 10)
  1746. .filter(r => r.score >= (minScore || 0.3))
  1747. .map(r => ({
  1748. file: r.displayPath,
  1749. title: r.title,
  1750. score: Math.round(r.score * 100) / 100,
  1751. context: getContextForFile(db, r.file),
  1752. snippet: extractSnippet(r.body, query, 300).snippet,
  1753. }));
  1754. db.close();
  1755. return {
  1756. content: [
  1757. {
  1758. type: "text",
  1759. mimeType: "text/csv",
  1760. text: toMcpCsv(filtered),
  1761. },
  1762. ],
  1763. };
  1764. }
  1765. );
  1766. // Tool: query (Hybrid with reranking)
  1767. server.registerTool(
  1768. "qmd_query",
  1769. {
  1770. title: "Hybrid Query (Best Quality)",
  1771. description: "Highest quality search combining BM25 + vector + query expansion + LLM reranking. Slower but most accurate. Use for important searches.",
  1772. inputSchema: {
  1773. query: z.string().describe("Natural language query - describe what you're looking for"),
  1774. limit: z.number().optional().default(10).describe("Maximum number of results (default: 10)"),
  1775. minScore: z.number().optional().default(0).describe("Minimum relevance score 0-1 (default: 0)"),
  1776. },
  1777. },
  1778. async ({ query, limit, minScore }) => {
  1779. const db = getDb();
  1780. // Expand query
  1781. const queries = await expandQuery(query, DEFAULT_QUERY_MODEL, db);
  1782. // Collect ranked lists
  1783. const rankedLists: RankedResult[][] = [];
  1784. const hasVectors = !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  1785. for (const q of queries) {
  1786. const ftsResults = searchFTS(db, q, 20);
  1787. if (ftsResults.length > 0) {
  1788. rankedLists.push(ftsResults.map(r => ({ file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score })));
  1789. }
  1790. if (hasVectors) {
  1791. const vecResults = await searchVec(db, q, DEFAULT_EMBED_MODEL, 20);
  1792. if (vecResults.length > 0) {
  1793. rankedLists.push(vecResults.map(r => ({ file: r.file, displayPath: r.displayPath, title: r.title, body: r.body, score: r.score })));
  1794. }
  1795. }
  1796. }
  1797. // RRF fusion
  1798. const weights = rankedLists.map((_, i) => i < 2 ? 2.0 : 1.0);
  1799. const fused = reciprocalRankFusion(rankedLists, weights);
  1800. const candidates = fused.slice(0, 30);
  1801. // Rerank
  1802. const reranked = await rerank(
  1803. query,
  1804. candidates.map(c => ({ file: c.file, text: c.body })),
  1805. DEFAULT_RERANK_MODEL,
  1806. db
  1807. );
  1808. // Blend scores
  1809. const candidateMap = new Map(candidates.map(c => [c.file, { displayPath: c.displayPath, title: c.title, body: c.body }]));
  1810. const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
  1811. const finalResults = reranked.map(r => {
  1812. const rrfRank = rrfRankMap.get(r.file) || candidates.length;
  1813. let rrfWeight: number;
  1814. if (rrfRank <= 3) rrfWeight = 0.75;
  1815. else if (rrfRank <= 10) rrfWeight = 0.60;
  1816. else rrfWeight = 0.40;
  1817. const rrfScore = 1 / rrfRank;
  1818. const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
  1819. const candidate = candidateMap.get(r.file);
  1820. return {
  1821. file: candidate?.displayPath || "",
  1822. title: candidate?.title || "",
  1823. score: Math.round(blendedScore * 100) / 100,
  1824. context: getContextForFile(db, r.file),
  1825. snippet: extractSnippet(candidate?.body || "", query, 300).snippet,
  1826. };
  1827. }).filter(r => r.score >= (minScore || 0)).slice(0, limit || 10);
  1828. db.close();
  1829. return {
  1830. content: [
  1831. {
  1832. type: "text",
  1833. mimeType: "text/csv",
  1834. text: toMcpCsv(finalResults),
  1835. },
  1836. ],
  1837. };
  1838. }
  1839. );
  1840. // Tool: get (Retrieve document)
  1841. server.registerTool(
  1842. "qmd_get",
  1843. {
  1844. title: "Get Document",
  1845. description: "Retrieve the full content of a document by its file path. Use paths from search results.",
  1846. inputSchema: {
  1847. file: z.string().describe("File path from search results (e.g., 'pages/meeting.md' or 'pages/meeting.md:100' to start at line 100)"),
  1848. fromLine: z.number().optional().describe("Start from this line number (1-indexed)"),
  1849. maxLines: z.number().optional().describe("Maximum number of lines to return"),
  1850. },
  1851. },
  1852. async ({ file, fromLine, maxLines }) => {
  1853. const db = getDb();
  1854. let filepath = file;
  1855. const colonMatch = filepath.match(/:(\d+)$/);
  1856. if (colonMatch && !fromLine) {
  1857. fromLine = parseInt(colonMatch[1], 10);
  1858. filepath = filepath.slice(0, -colonMatch[0].length);
  1859. }
  1860. if (filepath.startsWith("~/")) {
  1861. filepath = homedir() + filepath.slice(1);
  1862. }
  1863. let doc = db.prepare(`SELECT filepath, body FROM documents WHERE filepath = ? AND active = 1`).get(filepath) as { filepath: string; body: string } | null;
  1864. if (!doc) {
  1865. doc = db.prepare(`SELECT filepath, body FROM documents WHERE filepath LIKE ? AND active = 1 LIMIT 1`).get(`%${filepath}`) as { filepath: string; body: string } | null;
  1866. }
  1867. if (!doc) {
  1868. db.close();
  1869. return {
  1870. content: [{ type: "text", text: `Error: Document not found: ${file}` }],
  1871. };
  1872. }
  1873. const context = getContextForFile(db, doc.filepath);
  1874. let output = doc.body;
  1875. if (fromLine !== undefined || maxLines !== undefined) {
  1876. const lines = output.split("\n");
  1877. const start = (fromLine || 1) - 1;
  1878. const end = maxLines !== undefined ? start + maxLines : lines.length;
  1879. output = lines.slice(start, end).join("\n");
  1880. }
  1881. db.close();
  1882. let result = "";
  1883. if (context) {
  1884. result += `Folder Context: ${context}\n---\n\n`;
  1885. }
  1886. result += output;
  1887. return {
  1888. content: [{ type: "text", text: result }],
  1889. };
  1890. }
  1891. );
  1892. // Tool: status (Index status)
  1893. server.registerTool(
  1894. "qmd_status",
  1895. {
  1896. title: "Index Status",
  1897. description: "Show the status of the QMD index: collections, document counts, and health information.",
  1898. inputSchema: {},
  1899. },
  1900. async () => {
  1901. const db = getDb();
  1902. const collections = db.prepare(`
  1903. SELECT c.id, c.pwd, c.glob_pattern, c.created_at,
  1904. COUNT(d.id) as active_count,
  1905. MAX(d.modified_at) as last_doc_update
  1906. FROM collections c
  1907. LEFT JOIN documents d ON d.collection_id = c.id AND d.active = 1
  1908. GROUP BY c.id
  1909. ORDER BY last_doc_update DESC
  1910. `).all() as { id: number; pwd: string; glob_pattern: string; created_at: string; active_count: number; last_doc_update: string | null }[];
  1911. const totalDocs = (db.prepare(`SELECT COUNT(*) as c FROM documents WHERE active = 1`).get() as { c: number }).c;
  1912. const needsEmbedding = getHashesNeedingEmbedding(db);
  1913. const hasVectors = !!db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
  1914. const status = {
  1915. totalDocuments: totalDocs,
  1916. needsEmbedding,
  1917. hasVectorIndex: hasVectors,
  1918. collections: collections.map(col => ({
  1919. path: col.pwd,
  1920. pattern: col.glob_pattern,
  1921. documents: col.active_count,
  1922. lastUpdated: col.last_doc_update || col.created_at,
  1923. })),
  1924. };
  1925. db.close();
  1926. return {
  1927. content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
  1928. };
  1929. }
  1930. );
  1931. // Connect via stdio
  1932. const transport = new StdioServerTransport();
  1933. await server.connect(transport);
  1934. }
  1935. // Parse CLI arguments using util.parseArgs
  1936. function parseCLI() {
  1937. const { values, positionals } = parseArgs({
  1938. args: Bun.argv.slice(2), // Skip bun and script path
  1939. options: {
  1940. // Global options
  1941. index: { type: "string" },
  1942. help: { type: "boolean", short: "h" },
  1943. // Search options
  1944. n: { type: "string" },
  1945. "min-score": { type: "string" },
  1946. all: { type: "boolean" },
  1947. full: { type: "boolean" },
  1948. csv: { type: "boolean" },
  1949. md: { type: "boolean" },
  1950. xml: { type: "boolean" },
  1951. files: { type: "boolean" },
  1952. json: { type: "boolean" },
  1953. // Add options
  1954. drop: { type: "boolean" },
  1955. // Embed options
  1956. force: { type: "boolean", short: "f" },
  1957. // Get options
  1958. l: { type: "string" }, // max lines
  1959. from: { type: "string" }, // start line
  1960. },
  1961. allowPositionals: true,
  1962. strict: false, // Allow unknown options to pass through
  1963. });
  1964. // Set global index name
  1965. if (values.index) {
  1966. customIndexName = values.index;
  1967. }
  1968. // Determine output format
  1969. let format: OutputFormat = "cli";
  1970. if (values.csv) format = "csv";
  1971. else if (values.md) format = "md";
  1972. else if (values.xml) format = "xml";
  1973. else if (values.files) format = "files";
  1974. else if (values.json) format = "json";
  1975. // Default limit: 20 for --files/--json, 5 otherwise
  1976. // --all means return all results (use very large limit)
  1977. const defaultLimit = (format === "files" || format === "json") ? 20 : 5;
  1978. const isAll = values.all || false;
  1979. const opts: OutputOptions = {
  1980. format,
  1981. full: values.full || false,
  1982. limit: isAll ? 100000 : (values.n ? parseInt(values.n, 10) || defaultLimit : defaultLimit),
  1983. minScore: values["min-score"] ? parseFloat(values["min-score"]) || 0 : 0,
  1984. all: isAll,
  1985. };
  1986. return {
  1987. command: positionals[0] || "",
  1988. args: positionals.slice(1),
  1989. query: positionals.slice(1).join(" "),
  1990. opts,
  1991. values,
  1992. };
  1993. }
  1994. function showHelp(): void {
  1995. console.log("Usage:");
  1996. console.log(" qmd add [--drop] [glob] - Add/update collection from $PWD (default: **/*.md)");
  1997. console.log(" qmd add-context <path> <text> - Add context description for files under path");
  1998. console.log(" qmd get <file>[:line] [-l N] [--from N] - Get document (optionally from line, max N lines)");
  1999. console.log(" qmd status - Show index status and collections");
  2000. console.log(" qmd update-all - Re-index all collections");
  2001. console.log(" qmd embed [-f] - Create vector embeddings (chunks ~6KB each)");
  2002. console.log(" qmd cleanup - Remove cache and orphaned data, vacuum DB");
  2003. console.log(" qmd search <query> - Full-text search (BM25)");
  2004. console.log(" qmd vsearch <query> - Vector similarity search");
  2005. console.log(" qmd query <query> - Combined search with query expansion + reranking");
  2006. console.log(" qmd mcp - Start MCP server (for AI agent integration)");
  2007. console.log("");
  2008. console.log("Global options:");
  2009. console.log(" --index <name> - Use custom index name (default: index)");
  2010. console.log("");
  2011. console.log("Search options:");
  2012. console.log(" -n <num> - Number of results (default: 5, or 20 for --files)");
  2013. console.log(" --all - Return all matches (use with --min-score to filter)");
  2014. console.log(" --min-score <num> - Minimum similarity score");
  2015. console.log(" --full - Output full document instead of snippet");
  2016. console.log(" --files - Output score,filepath,context (default: 20 results)");
  2017. console.log(" --json - JSON output with snippets (default: 20 results)");
  2018. console.log(" --csv - CSV output with snippets");
  2019. console.log(" --md - Markdown output");
  2020. console.log(" --xml - XML output");
  2021. console.log("");
  2022. console.log("Environment:");
  2023. console.log(" OLLAMA_URL - Ollama server URL (default: http://localhost:11434)");
  2024. console.log("");
  2025. console.log("Models:");
  2026. console.log(` Embedding: ${DEFAULT_EMBED_MODEL}`);
  2027. console.log(` Reranking: ${DEFAULT_RERANK_MODEL}`);
  2028. console.log("");
  2029. console.log(`Index: ${getDbPath()}`);
  2030. }
  2031. // Main CLI
  2032. const cli = parseCLI();
  2033. if (!cli.command || cli.values.help) {
  2034. showHelp();
  2035. process.exit(cli.values.help ? 0 : 1);
  2036. }
  2037. switch (cli.command) {
  2038. case "add": {
  2039. const globArg = cli.args[0];
  2040. // Treat "." as "use default glob in current directory"
  2041. const globPattern = (!globArg || globArg === ".") ? DEFAULT_GLOB : globArg;
  2042. if (cli.values.drop) {
  2043. await dropCollection(globPattern);
  2044. } else {
  2045. await indexFiles(globPattern);
  2046. }
  2047. break;
  2048. }
  2049. case "add-context": {
  2050. // qmd add-context <path> <context> OR qmd add-context <context> (uses .)
  2051. if (cli.args.length === 0) {
  2052. console.error("Usage: qmd add-context <path> <context>");
  2053. console.error(" qmd add-context . \"Description of files in current directory\"");
  2054. process.exit(1);
  2055. }
  2056. let pathArg: string;
  2057. let contextText: string;
  2058. if (cli.args.length === 1) {
  2059. // Single arg = context for current directory
  2060. pathArg = ".";
  2061. contextText = cli.args[0];
  2062. } else {
  2063. pathArg = cli.args[0];
  2064. contextText = cli.args.slice(1).join(" ");
  2065. }
  2066. await addContext(pathArg, contextText);
  2067. break;
  2068. }
  2069. case "get": {
  2070. if (!cli.args[0]) {
  2071. console.error("Usage: qmd get <filepath>[:line] [--from <line>] [-l <lines>]");
  2072. process.exit(1);
  2073. }
  2074. const fromLine = cli.values.from ? parseInt(cli.values.from as string, 10) : undefined;
  2075. const maxLines = cli.values.l ? parseInt(cli.values.l as string, 10) : undefined;
  2076. getDocument(cli.args[0], fromLine, maxLines);
  2077. break;
  2078. }
  2079. case "status":
  2080. showStatus();
  2081. break;
  2082. case "update-all":
  2083. await updateAllCollections();
  2084. break;
  2085. case "embed":
  2086. await vectorIndex(DEFAULT_EMBED_MODEL, cli.values.force || false);
  2087. break;
  2088. case "search":
  2089. if (!cli.query) {
  2090. console.error("Usage: qmd search [options] <query>");
  2091. process.exit(1);
  2092. }
  2093. search(cli.query, cli.opts);
  2094. break;
  2095. case "vsearch":
  2096. if (!cli.query) {
  2097. console.error("Usage: qmd vsearch [options] <query>");
  2098. process.exit(1);
  2099. }
  2100. // Default min-score for vector search is 0.3
  2101. if (!cli.values["min-score"]) {
  2102. cli.opts.minScore = 0.3;
  2103. }
  2104. await vectorSearch(cli.query, cli.opts);
  2105. break;
  2106. case "query":
  2107. if (!cli.query) {
  2108. console.error("Usage: qmd query [options] <query>");
  2109. process.exit(1);
  2110. }
  2111. await querySearch(cli.query, cli.opts);
  2112. break;
  2113. case "mcp":
  2114. await startMcpServer();
  2115. break;
  2116. case "cleanup": {
  2117. const db = getDb();
  2118. // 1. Clear ollama_cache
  2119. const cacheCount = db.prepare(`SELECT COUNT(*) as c FROM ollama_cache`).get() as { c: number };
  2120. db.exec(`DELETE FROM ollama_cache`);
  2121. console.log(`${c.green}✓${c.reset} Cleared ${cacheCount.c} cached API responses`);
  2122. // 2. Remove orphaned vectors (no active document with that hash)
  2123. const orphanedVecs = db.prepare(`
  2124. SELECT COUNT(*) as c FROM content_vectors cv
  2125. WHERE NOT EXISTS (
  2126. SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
  2127. )
  2128. `).get() as { c: number };
  2129. if (orphanedVecs.c > 0) {
  2130. db.exec(`
  2131. DELETE FROM vectors_vec WHERE hash_seq IN (
  2132. SELECT cv.hash || '_' || cv.seq FROM content_vectors cv
  2133. WHERE NOT EXISTS (
  2134. SELECT 1 FROM documents d WHERE d.hash = cv.hash AND d.active = 1
  2135. )
  2136. )
  2137. `);
  2138. db.exec(`
  2139. DELETE FROM content_vectors WHERE hash NOT IN (
  2140. SELECT hash FROM documents WHERE active = 1
  2141. )
  2142. `);
  2143. console.log(`${c.green}✓${c.reset} Removed ${orphanedVecs.c} orphaned embedding chunks`);
  2144. } else {
  2145. console.log(`${c.dim}No orphaned embeddings to remove${c.reset}`);
  2146. }
  2147. // 3. Count inactive documents
  2148. const inactiveDocs = db.prepare(`SELECT COUNT(*) as c FROM documents WHERE active = 0`).get() as { c: number };
  2149. if (inactiveDocs.c > 0) {
  2150. db.exec(`DELETE FROM documents WHERE active = 0`);
  2151. console.log(`${c.green}✓${c.reset} Removed ${inactiveDocs.c} inactive document records`);
  2152. }
  2153. // 4. Vacuum to reclaim space
  2154. db.exec(`VACUUM`);
  2155. console.log(`${c.green}✓${c.reset} Database vacuumed`);
  2156. db.close();
  2157. break;
  2158. }
  2159. default:
  2160. console.error(`Unknown command: ${cli.command}`);
  2161. console.error("Run 'qmd --help' for usage.");
  2162. process.exit(1);
  2163. }