qmd.ts 118 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328
  1. import { openDatabase } from "../db.js";
  2. import type { Database } from "../db.js";
  3. import fastGlob from "fast-glob";
  4. import { execSync, spawn as nodeSpawn } from "child_process";
  5. import { fileURLToPath } from "url";
  6. import { dirname, join as pathJoin, relative as relativePath } from "path";
  7. import { parseArgs } from "util";
  8. import { readFileSync, realpathSync, statSync, existsSync, unlinkSync, writeFileSync, openSync, closeSync, mkdirSync, lstatSync, rmSync, symlinkSync, readlinkSync } from "fs";
  9. import { createInterface } from "readline/promises";
  10. import {
  11. getPwd,
  12. getRealPath,
  13. homedir,
  14. resolve,
  15. enableProductionMode,
  16. searchFTS,
  17. extractSnippet,
  18. getContextForFile,
  19. getContextForPath,
  20. listCollections,
  21. removeCollection,
  22. renameCollection,
  23. findSimilarFiles,
  24. findDocumentByDocid,
  25. isDocid,
  26. matchFilesByGlob,
  27. getHashesNeedingEmbedding,
  28. clearAllEmbeddings,
  29. insertEmbedding,
  30. getStatus,
  31. hashContent,
  32. extractTitle,
  33. formatDocForEmbedding,
  34. chunkDocumentByTokens,
  35. clearCache,
  36. getCacheKey,
  37. getCachedResult,
  38. setCachedResult,
  39. getIndexHealth,
  40. parseVirtualPath,
  41. buildVirtualPath,
  42. isVirtualPath,
  43. resolveVirtualPath,
  44. toVirtualPath,
  45. insertContent,
  46. insertDocument,
  47. findActiveDocument,
  48. updateDocumentTitle,
  49. updateDocument,
  50. deactivateDocument,
  51. getActiveDocumentPaths,
  52. cleanupOrphanedContent,
  53. deleteLLMCache,
  54. deleteInactiveDocuments,
  55. cleanupOrphanedVectors,
  56. vacuumDatabase,
  57. getCollectionsWithoutContext,
  58. getTopLevelPathsWithoutContext,
  59. handelize,
  60. hybridQuery,
  61. vectorSearchQuery,
  62. structuredSearch,
  63. addLineNumbers,
  64. type ExpandedQuery,
  65. type HybridQueryExplain,
  66. DEFAULT_EMBED_MODEL,
  67. DEFAULT_EMBED_MAX_BATCH_BYTES,
  68. DEFAULT_EMBED_MAX_DOCS_PER_BATCH,
  69. DEFAULT_RERANK_MODEL,
  70. DEFAULT_GLOB,
  71. DEFAULT_MULTI_GET_MAX_BYTES,
  72. createStore,
  73. getDefaultDbPath,
  74. reindexCollection,
  75. generateEmbeddings,
  76. syncConfigToDb,
  77. type ReindexResult,
  78. type ChunkStrategy,
  79. } from "../store.js";
  80. import { disposeDefaultLlamaCpp, getDefaultLlamaCpp, setDefaultLlamaCpp, LlamaCpp, withLLMSession, pullModels, DEFAULT_EMBED_MODEL_URI, DEFAULT_GENERATE_MODEL_URI, DEFAULT_RERANK_MODEL_URI, DEFAULT_MODEL_CACHE_DIR } from "../llm.js";
  81. import {
  82. formatSearchResults,
  83. formatDocuments,
  84. escapeXml,
  85. escapeCSV,
  86. type OutputFormat,
  87. } from "./formatter.js";
  88. import {
  89. getCollection as getCollectionFromYaml,
  90. listCollections as yamlListCollections,
  91. getDefaultCollectionNames,
  92. addContext as yamlAddContext,
  93. removeContext as yamlRemoveContext,
  94. removeCollection as yamlRemoveCollectionFn,
  95. renameCollection as yamlRenameCollectionFn,
  96. setGlobalContext,
  97. listAllContexts,
  98. setConfigIndexName,
  99. loadConfig,
  100. } from "../collections.js";
  101. import { getEmbeddedQmdSkillContent, getEmbeddedQmdSkillFiles } from "../embedded-skills.js";
  102. // Enable production mode - allows using default database path
  103. // Tests must set INDEX_PATH or use createStore() with explicit path
  104. enableProductionMode();
  105. // =============================================================================
  106. // Store/DB lifecycle (no legacy singletons in store.ts)
  107. // =============================================================================
  108. let store: ReturnType<typeof createStore> | null = null;
  109. let storeDbPathOverride: string | undefined;
  110. function getStore(): ReturnType<typeof createStore> {
  111. if (!store) {
  112. store = createStore(storeDbPathOverride);
  113. // Sync YAML config into SQLite store_collections so store.ts reads from DB
  114. try {
  115. const config = loadConfig();
  116. syncConfigToDb(store.db, config);
  117. if (config.models) {
  118. setDefaultLlamaCpp(new LlamaCpp({
  119. embedModel: config.models.embed,
  120. generateModel: config.models.generate,
  121. rerankModel: config.models.rerank,
  122. }));
  123. }
  124. } catch {
  125. // Config may not exist yet — that's fine, DB works without it
  126. }
  127. }
  128. return store;
  129. }
  130. function getDb(): Database {
  131. return getStore().db;
  132. }
  133. /** Re-sync YAML config into SQLite after CLI mutations (add/remove/rename collection, context changes) */
  134. function resyncConfig(): void {
  135. const s = getStore();
  136. try {
  137. const config = loadConfig();
  138. // Clear config hash to force re-sync
  139. s.db.prepare(`DELETE FROM store_config WHERE key = 'config_hash'`).run();
  140. syncConfigToDb(s.db, config);
  141. } catch {
  142. // Config may not exist — that's fine
  143. }
  144. }
  145. function closeDb(): void {
  146. if (store) {
  147. store.close();
  148. store = null;
  149. }
  150. }
  151. function getDbPath(): string {
  152. return store?.dbPath ?? storeDbPathOverride ?? getDefaultDbPath();
  153. }
  154. function setIndexName(name: string | null): void {
  155. let normalizedName = name;
  156. // Normalize relative paths to prevent malformed database paths
  157. if (name && name.includes('/')) {
  158. const { resolve } = require('path');
  159. const { cwd } = require('process');
  160. const absolutePath = resolve(cwd(), name);
  161. // Replace path separators with underscores to create a valid filename
  162. normalizedName = absolutePath.replace(/\//g, '_').replace(/^_/, '');
  163. }
  164. storeDbPathOverride = normalizedName ? getDefaultDbPath(normalizedName) : undefined;
  165. // Reset open handle so next use opens the new index
  166. closeDb();
  167. }
  168. function ensureVecTable(_db: Database, dimensions: number): void {
  169. // Store owns the DB; ignore `_db` and ensure vec table on the active store
  170. getStore().ensureVecTable(dimensions);
  171. }
  172. // Terminal colors (respects NO_COLOR env)
  173. const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
  174. const c = {
  175. reset: useColor ? "\x1b[0m" : "",
  176. dim: useColor ? "\x1b[2m" : "",
  177. bold: useColor ? "\x1b[1m" : "",
  178. cyan: useColor ? "\x1b[36m" : "",
  179. yellow: useColor ? "\x1b[33m" : "",
  180. green: useColor ? "\x1b[32m" : "",
  181. magenta: useColor ? "\x1b[35m" : "",
  182. blue: useColor ? "\x1b[34m" : "",
  183. };
  184. // Terminal cursor control
  185. const cursor = {
  186. hide() { process.stderr.write('\x1b[?25l'); },
  187. show() { process.stderr.write('\x1b[?25h'); },
  188. };
  189. // Ensure cursor is restored on exit
  190. process.on('SIGINT', () => { cursor.show(); process.exit(130); });
  191. process.on('SIGTERM', () => { cursor.show(); process.exit(143); });
  192. // Terminal progress bar using OSC 9;4 escape sequence (TTY only)
  193. const isTTY = process.stderr.isTTY;
  194. const progress = {
  195. set(percent: number) {
  196. if (isTTY) process.stderr.write(`\x1b]9;4;1;${Math.round(percent)}\x07`);
  197. },
  198. clear() {
  199. if (isTTY) process.stderr.write(`\x1b]9;4;0\x07`);
  200. },
  201. indeterminate() {
  202. if (isTTY) process.stderr.write(`\x1b]9;4;3\x07`);
  203. },
  204. error() {
  205. if (isTTY) process.stderr.write(`\x1b]9;4;2\x07`);
  206. },
  207. };
  208. // Format seconds into human-readable ETA
  209. function formatETA(seconds: number): string {
  210. if (seconds < 60) return `${Math.round(seconds)}s`;
  211. if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
  212. return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
  213. }
  214. // Check index health and print warnings/tips
  215. function checkIndexHealth(db: Database): void {
  216. const { needsEmbedding, totalDocs, daysStale } = getIndexHealth(db);
  217. // Warn if many docs need embedding
  218. if (needsEmbedding > 0) {
  219. const pct = Math.round((needsEmbedding / totalDocs) * 100);
  220. if (pct >= 10) {
  221. process.stderr.write(`${c.yellow}Warning: ${needsEmbedding} documents (${pct}%) need embeddings. Run 'qmd embed' for better results.${c.reset}\n`);
  222. } else {
  223. process.stderr.write(`${c.dim}Tip: ${needsEmbedding} documents need embeddings. Run 'qmd embed' to index them.${c.reset}\n`);
  224. }
  225. }
  226. // Check if most recent document update is older than 2 weeks
  227. if (daysStale !== null && daysStale >= 14) {
  228. process.stderr.write(`${c.dim}Tip: Index last updated ${daysStale} days ago. Run 'qmd update' to refresh.${c.reset}\n`);
  229. }
  230. }
  231. // Compute unique display path for a document
  232. // Always include at least parent folder + filename, add more parent dirs until unique
  233. function computeDisplayPath(
  234. filepath: string,
  235. collectionPath: string,
  236. existingPaths: Set<string>
  237. ): string {
  238. // Get path relative to collection (include collection dir name)
  239. const collectionDir = collectionPath.replace(/\/$/, '');
  240. const collectionName = collectionDir.split('/').pop() || '';
  241. let relativePath: string;
  242. if (filepath.startsWith(collectionDir + '/')) {
  243. // filepath is under collection: use collection name + relative path
  244. relativePath = collectionName + filepath.slice(collectionDir.length);
  245. } else {
  246. // Fallback: just use the filepath
  247. relativePath = filepath;
  248. }
  249. const parts = relativePath.split('/').filter(p => p.length > 0);
  250. // Always include at least parent folder + filename (minimum 2 parts if available)
  251. // Then add more parent dirs until unique
  252. const minParts = Math.min(2, parts.length);
  253. for (let i = parts.length - minParts; i >= 0; i--) {
  254. const candidate = parts.slice(i).join('/');
  255. if (!existingPaths.has(candidate)) {
  256. return candidate;
  257. }
  258. }
  259. // Absolute fallback: use full path (should be unique)
  260. return filepath;
  261. }
  262. function formatTimeAgo(date: Date): string {
  263. const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
  264. if (seconds < 60) return `${seconds}s ago`;
  265. const minutes = Math.floor(seconds / 60);
  266. if (minutes < 60) return `${minutes}m ago`;
  267. const hours = Math.floor(minutes / 60);
  268. if (hours < 24) return `${hours}h ago`;
  269. const days = Math.floor(hours / 24);
  270. return `${days}d ago`;
  271. }
  272. function formatMs(ms: number): string {
  273. if (ms < 1000) return `${ms}ms`;
  274. return `${(ms / 1000).toFixed(1)}s`;
  275. }
  276. function formatBytes(bytes: number): string {
  277. if (bytes < 1024) return `${bytes} B`;
  278. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  279. if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
  280. return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
  281. }
  282. async function showStatus(): Promise<void> {
  283. const dbPath = getDbPath();
  284. const db = getDb();
  285. // Collections are defined in YAML; no duplicate cleanup needed.
  286. // Collections are defined in YAML; no duplicate cleanup needed.
  287. // Index size
  288. let indexSize = 0;
  289. try {
  290. const stat = statSync(dbPath).size;
  291. indexSize = stat;
  292. } catch { }
  293. // Collections info (from YAML + database stats)
  294. const collections = listCollections(db);
  295. // Overall stats
  296. const totalDocs = db.prepare(`SELECT COUNT(*) as count FROM documents WHERE active = 1`).get() as { count: number };
  297. const vectorCount = db.prepare(`SELECT COUNT(*) as count FROM content_vectors`).get() as { count: number };
  298. const needsEmbedding = getHashesNeedingEmbedding(db);
  299. // Most recent update across all collections
  300. const mostRecent = db.prepare(`SELECT MAX(modified_at) as latest FROM documents WHERE active = 1`).get() as { latest: string | null };
  301. console.log(`${c.bold}QMD Status${c.reset}\n`);
  302. console.log(`Index: ${dbPath}`);
  303. console.log(`Size: ${formatBytes(indexSize)}`);
  304. // MCP daemon status (check PID file liveness)
  305. const mcpCacheDir = process.env.XDG_CACHE_HOME
  306. ? resolve(process.env.XDG_CACHE_HOME, "qmd")
  307. : resolve(homedir(), ".cache", "qmd");
  308. const mcpPidPath = resolve(mcpCacheDir, "mcp.pid");
  309. if (existsSync(mcpPidPath)) {
  310. const mcpPid = parseInt(readFileSync(mcpPidPath, "utf-8").trim());
  311. try {
  312. process.kill(mcpPid, 0);
  313. console.log(`MCP: ${c.green}running${c.reset} (PID ${mcpPid})`);
  314. } catch {
  315. unlinkSync(mcpPidPath);
  316. // Stale PID file cleaned up silently
  317. }
  318. }
  319. console.log("");
  320. console.log(`${c.bold}Documents${c.reset}`);
  321. console.log(` Total: ${totalDocs.count} files indexed`);
  322. console.log(` Vectors: ${vectorCount.count} embedded`);
  323. if (needsEmbedding > 0) {
  324. console.log(` ${c.yellow}Pending: ${needsEmbedding} need embedding${c.reset} (run 'qmd embed')`);
  325. }
  326. if (mostRecent.latest) {
  327. const lastUpdate = new Date(mostRecent.latest);
  328. console.log(` Updated: ${formatTimeAgo(lastUpdate)}`);
  329. }
  330. // Get all contexts grouped by collection (from YAML)
  331. const allContexts = listAllContexts();
  332. const contextsByCollection = new Map<string, { path_prefix: string; context: string }[]>();
  333. for (const ctx of allContexts) {
  334. // Group contexts by collection name
  335. if (!contextsByCollection.has(ctx.collection)) {
  336. contextsByCollection.set(ctx.collection, []);
  337. }
  338. contextsByCollection.get(ctx.collection)!.push({
  339. path_prefix: ctx.path,
  340. context: ctx.context
  341. });
  342. }
  343. // AST chunking status
  344. try {
  345. const { getASTStatus } = await import("../ast.js");
  346. const ast = await getASTStatus();
  347. console.log(`\n${c.bold}AST Chunking${c.reset}`);
  348. if (ast.available) {
  349. const ok = ast.languages.filter(l => l.available).map(l => l.language);
  350. const fail = ast.languages.filter(l => !l.available);
  351. console.log(` Status: ${c.green}active${c.reset}`);
  352. console.log(` Languages: ${ok.join(", ")}`);
  353. if (fail.length > 0) {
  354. for (const f of fail) {
  355. console.log(` ${c.yellow}Unavailable: ${f.language} (${f.error})${c.reset}`);
  356. }
  357. }
  358. } else {
  359. console.log(` Status: ${c.yellow}unavailable${c.reset} (falling back to regex chunking)`);
  360. for (const l of ast.languages) {
  361. if (l.error) console.log(` ${c.dim}${l.language}: ${l.error}${c.reset}`);
  362. }
  363. }
  364. } catch {
  365. console.log(`\n${c.bold}AST Chunking${c.reset}`);
  366. console.log(` Status: ${c.dim}not available${c.reset}`);
  367. }
  368. if (collections.length > 0) {
  369. console.log(`\n${c.bold}Collections${c.reset}`);
  370. for (const col of collections) {
  371. const lastMod = col.last_modified ? formatTimeAgo(new Date(col.last_modified)) : "never";
  372. const contexts = contextsByCollection.get(col.name) || [];
  373. console.log(` ${c.cyan}${col.name}${c.reset} ${c.dim}(qmd://${col.name}/)${c.reset}`);
  374. console.log(` ${c.dim}Pattern:${c.reset} ${col.glob_pattern}`);
  375. console.log(` ${c.dim}Files:${c.reset} ${col.active_count} (updated ${lastMod})`);
  376. if (contexts.length > 0) {
  377. console.log(` ${c.dim}Contexts:${c.reset} ${contexts.length}`);
  378. for (const ctx of contexts) {
  379. // Handle both empty string and '/' as root context
  380. const pathDisplay = (ctx.path_prefix === '' || ctx.path_prefix === '/') ? '/' : `/${ctx.path_prefix}`;
  381. const contextPreview = ctx.context.length > 60
  382. ? ctx.context.substring(0, 57) + '...'
  383. : ctx.context;
  384. console.log(` ${c.dim}${pathDisplay}:${c.reset} ${contextPreview}`);
  385. }
  386. }
  387. }
  388. // Show examples of virtual paths
  389. console.log(`\n${c.bold}Examples${c.reset}`);
  390. console.log(` ${c.dim}# List files in a collection${c.reset}`);
  391. if (collections.length > 0 && collections[0]) {
  392. console.log(` qmd ls ${collections[0].name}`);
  393. }
  394. console.log(` ${c.dim}# Get a document${c.reset}`);
  395. if (collections.length > 0 && collections[0]) {
  396. console.log(` qmd get qmd://${collections[0].name}/path/to/file.md`);
  397. }
  398. console.log(` ${c.dim}# Search within a collection${c.reset}`);
  399. if (collections.length > 0 && collections[0]) {
  400. console.log(` qmd search "query" -c ${collections[0].name}`);
  401. }
  402. } else {
  403. console.log(`\n${c.dim}No collections. Run 'qmd collection add .' to index markdown files.${c.reset}`);
  404. }
  405. // Models
  406. {
  407. // hf:org/repo/file.gguf → https://huggingface.co/org/repo
  408. const hfLink = (uri: string) => {
  409. const match = uri.match(/^hf:([^/]+\/[^/]+)\//);
  410. return match ? `https://huggingface.co/${match[1]}` : uri;
  411. };
  412. console.log(`\n${c.bold}Models${c.reset}`);
  413. console.log(` Embedding: ${hfLink(DEFAULT_EMBED_MODEL_URI)}`);
  414. console.log(` Reranking: ${hfLink(DEFAULT_RERANK_MODEL_URI)}`);
  415. console.log(` Generation: ${hfLink(DEFAULT_GENERATE_MODEL_URI)}`);
  416. }
  417. // Device / GPU info
  418. try {
  419. const llm = getDefaultLlamaCpp();
  420. const device = await llm.getDeviceInfo();
  421. console.log(`\n${c.bold}Device${c.reset}`);
  422. if (device.gpu) {
  423. console.log(` GPU: ${c.green}${device.gpu}${c.reset} (offloading: ${device.gpuOffloading ? 'yes' : 'no'})`);
  424. if (device.gpuDevices.length > 0) {
  425. // Deduplicate and count GPUs
  426. const counts = new Map<string, number>();
  427. for (const name of device.gpuDevices) {
  428. counts.set(name, (counts.get(name) || 0) + 1);
  429. }
  430. const deviceStr = Array.from(counts.entries())
  431. .map(([name, count]) => count > 1 ? `${count}× ${name}` : name)
  432. .join(', ');
  433. console.log(` Devices: ${deviceStr}`);
  434. }
  435. if (device.vram) {
  436. console.log(` VRAM: ${formatBytes(device.vram.free)} free / ${formatBytes(device.vram.total)} total`);
  437. }
  438. } else {
  439. console.log(` GPU: ${c.yellow}none${c.reset} (running on CPU — models will be slow)`);
  440. console.log(` ${c.dim}Tip: Install CUDA, Vulkan, or Metal support for GPU acceleration.${c.reset}`);
  441. }
  442. console.log(` CPU: ${device.cpuCores} math cores`);
  443. } catch {
  444. // Don't fail status if LLM init fails
  445. }
  446. // Tips section
  447. const tips: string[] = [];
  448. // Check for collections without context
  449. const collectionsWithoutContext = collections.filter(col => {
  450. const contexts = contextsByCollection.get(col.name) || [];
  451. return contexts.length === 0;
  452. });
  453. if (collectionsWithoutContext.length > 0) {
  454. const names = collectionsWithoutContext.map(c => c.name).slice(0, 3).join(', ');
  455. const more = collectionsWithoutContext.length > 3 ? ` +${collectionsWithoutContext.length - 3} more` : '';
  456. tips.push(`Add context to collections for better search results: ${names}${more}`);
  457. tips.push(` ${c.dim}qmd context add qmd://<name>/ "What this collection contains"${c.reset}`);
  458. tips.push(` ${c.dim}qmd context add qmd://<name>/meeting-notes "Weekly team meeting notes"${c.reset}`);
  459. }
  460. // Check for collections without update commands
  461. const collectionsWithoutUpdate = collections.filter(col => {
  462. const yamlCol = getCollectionFromYaml(col.name);
  463. return !yamlCol?.update;
  464. });
  465. if (collectionsWithoutUpdate.length > 0 && collections.length > 1) {
  466. const names = collectionsWithoutUpdate.map(c => c.name).slice(0, 3).join(', ');
  467. const more = collectionsWithoutUpdate.length > 3 ? ` +${collectionsWithoutUpdate.length - 3} more` : '';
  468. tips.push(`Add update commands to keep collections fresh: ${names}${more}`);
  469. tips.push(` ${c.dim}qmd collection update-cmd <name> 'git stash && git pull --rebase --ff-only && git stash pop'${c.reset}`);
  470. }
  471. if (tips.length > 0) {
  472. console.log(`\n${c.bold}Tips${c.reset}`);
  473. for (const tip of tips) {
  474. console.log(` ${tip}`);
  475. }
  476. }
  477. closeDb();
  478. }
  479. async function updateCollections(): Promise<void> {
  480. const db = getDb();
  481. const storeInstance = getStore();
  482. // Collections are defined in YAML; no duplicate cleanup needed.
  483. // Clear Ollama cache on update
  484. clearCache(db);
  485. const collections = listCollections(db);
  486. if (collections.length === 0) {
  487. console.log(`${c.dim}No collections found. Run 'qmd collection add .' to index markdown files.${c.reset}`);
  488. closeDb();
  489. return;
  490. }
  491. console.log(`${c.bold}Updating ${collections.length} collection(s)...${c.reset}\n`);
  492. for (let i = 0; i < collections.length; i++) {
  493. const col = collections[i];
  494. if (!col) continue;
  495. console.log(`${c.cyan}[${i + 1}/${collections.length}]${c.reset} ${c.bold}${col.name}${c.reset} ${c.dim}(${col.glob_pattern})${c.reset}`);
  496. // Execute custom update command if specified in YAML
  497. const yamlCol = getCollectionFromYaml(col.name);
  498. if (yamlCol?.update) {
  499. console.log(`${c.dim} Running update command: ${yamlCol.update}${c.reset}`);
  500. try {
  501. const proc = nodeSpawn("bash", ["-c", yamlCol.update], {
  502. cwd: col.pwd,
  503. stdio: ["ignore", "pipe", "pipe"],
  504. });
  505. const [output, errorOutput, exitCode] = await new Promise<[string, string, number]>((resolve, reject) => {
  506. let out = "";
  507. let err = "";
  508. proc.stdout?.on("data", (d: Buffer) => { out += d.toString(); });
  509. proc.stderr?.on("data", (d: Buffer) => { err += d.toString(); });
  510. proc.on("error", reject);
  511. proc.on("close", (code) => resolve([out, err, code ?? 1]));
  512. });
  513. if (output.trim()) {
  514. console.log(output.trim().split('\n').map(l => ` ${l}`).join('\n'));
  515. }
  516. if (errorOutput.trim()) {
  517. console.log(errorOutput.trim().split('\n').map(l => ` ${l}`).join('\n'));
  518. }
  519. if (exitCode !== 0) {
  520. console.log(`${c.yellow}✗ Update command failed with exit code ${exitCode}${c.reset}`);
  521. process.exit(exitCode);
  522. }
  523. } catch (err) {
  524. console.log(`${c.yellow}✗ Update command failed: ${err}${c.reset}`);
  525. process.exit(1);
  526. }
  527. }
  528. const startTime = Date.now();
  529. console.log(`Collection: ${col.pwd} (${col.glob_pattern})`);
  530. progress.indeterminate();
  531. const result = await reindexCollection(storeInstance, col.pwd, col.glob_pattern, col.name, {
  532. ignorePatterns: yamlCol?.ignore,
  533. onProgress: (info) => {
  534. progress.set((info.current / info.total) * 100);
  535. const elapsed = (Date.now() - startTime) / 1000;
  536. const rate = info.current / elapsed;
  537. const remaining = (info.total - info.current) / rate;
  538. const eta = info.current > 2 ? ` ETA: ${formatETA(remaining)}` : "";
  539. if (isTTY) process.stderr.write(`\rIndexing: ${info.current}/${info.total}${eta} `);
  540. },
  541. });
  542. progress.clear();
  543. console.log(`\nIndexed: ${result.indexed} new, ${result.updated} updated, ${result.unchanged} unchanged, ${result.removed} removed`);
  544. if (result.orphanedCleaned > 0) {
  545. console.log(`Cleaned up ${result.orphanedCleaned} orphaned content hash(es)`);
  546. }
  547. console.log("");
  548. }
  549. // Check if any documents need embedding (show once at end)
  550. const needsEmbedding = getHashesNeedingEmbedding(db);
  551. closeDb();
  552. console.log(`${c.green}✓ All collections updated.${c.reset}`);
  553. if (needsEmbedding > 0) {
  554. console.log(`\nRun 'qmd embed' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
  555. }
  556. }
  557. /**
  558. * Detect which collection (if any) contains the given filesystem path.
  559. * Returns { collectionId, collectionName, relativePath } or null if not in any collection.
  560. */
  561. function detectCollectionFromPath(db: Database, fsPath: string): { collectionName: string; relativePath: string } | null {
  562. const realPath = getRealPath(fsPath);
  563. // Find collections that this path is under from YAML
  564. const allCollections = yamlListCollections();
  565. // Find longest matching path
  566. let bestMatch: { name: string; path: string } | null = null;
  567. for (const coll of allCollections) {
  568. if (realPath.startsWith(coll.path + '/') || realPath === coll.path) {
  569. if (!bestMatch || coll.path.length > bestMatch.path.length) {
  570. bestMatch = { name: coll.name, path: coll.path };
  571. }
  572. }
  573. }
  574. if (!bestMatch) return null;
  575. // Calculate relative path
  576. let relativePath = realPath;
  577. if (relativePath.startsWith(bestMatch.path + '/')) {
  578. relativePath = relativePath.slice(bestMatch.path.length + 1);
  579. } else if (relativePath === bestMatch.path) {
  580. relativePath = '';
  581. }
  582. return {
  583. collectionName: bestMatch.name,
  584. relativePath
  585. };
  586. }
  587. async function contextAdd(pathArg: string | undefined, contextText: string): Promise<void> {
  588. const db = getDb();
  589. // Handle "/" as global context (applies to all collections)
  590. if (pathArg === '/') {
  591. setGlobalContext(contextText);
  592. resyncConfig();
  593. console.log(`${c.green}✓${c.reset} Set global context`);
  594. console.log(`${c.dim}Context: ${contextText}${c.reset}`);
  595. closeDb();
  596. return;
  597. }
  598. // Resolve path - defaults to current directory if not provided
  599. let fsPath = pathArg || '.';
  600. if (fsPath === '.' || fsPath === './') {
  601. fsPath = getPwd();
  602. } else if (fsPath.startsWith('~/')) {
  603. fsPath = homedir() + fsPath.slice(1);
  604. } else if (!fsPath.startsWith('/') && !fsPath.startsWith('qmd://')) {
  605. fsPath = resolve(getPwd(), fsPath);
  606. }
  607. // Handle virtual paths (qmd://collection/path)
  608. if (isVirtualPath(fsPath)) {
  609. const parsed = parseVirtualPath(fsPath);
  610. if (!parsed) {
  611. console.error(`${c.yellow}Invalid virtual path: ${fsPath}${c.reset}`);
  612. process.exit(1);
  613. }
  614. const coll = getCollectionFromYaml(parsed.collectionName);
  615. if (!coll) {
  616. console.error(`${c.yellow}Collection not found: ${parsed.collectionName}${c.reset}`);
  617. process.exit(1);
  618. }
  619. yamlAddContext(parsed.collectionName, parsed.path, contextText);
  620. resyncConfig();
  621. const displayPath = parsed.path
  622. ? `qmd://${parsed.collectionName}/${parsed.path}`
  623. : `qmd://${parsed.collectionName}/ (collection root)`;
  624. console.log(`${c.green}✓${c.reset} Added context for: ${displayPath}`);
  625. console.log(`${c.dim}Context: ${contextText}${c.reset}`);
  626. closeDb();
  627. return;
  628. }
  629. // Detect collection from filesystem path
  630. const detected = detectCollectionFromPath(db, fsPath);
  631. if (!detected) {
  632. console.error(`${c.yellow}Path is not in any indexed collection: ${fsPath}${c.reset}`);
  633. console.error(`${c.dim}Run 'qmd status' to see indexed collections${c.reset}`);
  634. process.exit(1);
  635. }
  636. yamlAddContext(detected.collectionName, detected.relativePath, contextText);
  637. resyncConfig();
  638. const displayPath = detected.relativePath ? `qmd://${detected.collectionName}/${detected.relativePath}` : `qmd://${detected.collectionName}/`;
  639. console.log(`${c.green}✓${c.reset} Added context for: ${displayPath}`);
  640. console.log(`${c.dim}Context: ${contextText}${c.reset}`);
  641. closeDb();
  642. }
  643. function contextList(): void {
  644. const db = getDb();
  645. const allContexts = listAllContexts();
  646. if (allContexts.length === 0) {
  647. console.log(`${c.dim}No contexts configured. Use 'qmd context add' to add one.${c.reset}`);
  648. closeDb();
  649. return;
  650. }
  651. console.log(`\n${c.bold}Configured Contexts${c.reset}\n`);
  652. let lastCollection = '';
  653. for (const ctx of allContexts) {
  654. if (ctx.collection !== lastCollection) {
  655. console.log(`${c.cyan}${ctx.collection}${c.reset}`);
  656. lastCollection = ctx.collection;
  657. }
  658. const displayPath = ctx.path ? ` ${ctx.path}` : ' / (root)';
  659. console.log(`${displayPath}`);
  660. console.log(` ${c.dim}${ctx.context}${c.reset}`);
  661. }
  662. closeDb();
  663. }
  664. function contextRemove(pathArg: string): void {
  665. if (pathArg === '/') {
  666. // Remove global context
  667. setGlobalContext(undefined);
  668. // Resync so SQLite store_config is updated
  669. const s = getStore();
  670. resyncConfig();
  671. closeDb();
  672. console.log(`${c.green}✓${c.reset} Removed global context`);
  673. return;
  674. }
  675. // Handle virtual paths
  676. if (isVirtualPath(pathArg)) {
  677. const parsed = parseVirtualPath(pathArg);
  678. if (!parsed) {
  679. console.error(`${c.yellow}Invalid virtual path: ${pathArg}${c.reset}`);
  680. process.exit(1);
  681. }
  682. const coll = getCollectionFromYaml(parsed.collectionName);
  683. if (!coll) {
  684. console.error(`${c.yellow}Collection not found: ${parsed.collectionName}${c.reset}`);
  685. process.exit(1);
  686. }
  687. const success = yamlRemoveContext(coll.name, parsed.path);
  688. if (!success) {
  689. console.error(`${c.yellow}No context found for: ${pathArg}${c.reset}`);
  690. process.exit(1);
  691. }
  692. console.log(`${c.green}✓${c.reset} Removed context for: ${pathArg}`);
  693. return;
  694. }
  695. // Handle filesystem paths
  696. let fsPath = pathArg;
  697. if (fsPath === '.' || fsPath === './') {
  698. fsPath = getPwd();
  699. } else if (fsPath.startsWith('~/')) {
  700. fsPath = homedir() + fsPath.slice(1);
  701. } else if (!fsPath.startsWith('/')) {
  702. fsPath = resolve(getPwd(), fsPath);
  703. }
  704. const db = getDb();
  705. const detected = detectCollectionFromPath(db, fsPath);
  706. closeDb();
  707. if (!detected) {
  708. console.error(`${c.yellow}Path is not in any indexed collection: ${fsPath}${c.reset}`);
  709. process.exit(1);
  710. }
  711. const success = yamlRemoveContext(detected.collectionName, detected.relativePath);
  712. if (!success) {
  713. console.error(`${c.yellow}No context found for: qmd://${detected.collectionName}/${detected.relativePath}${c.reset}`);
  714. process.exit(1);
  715. }
  716. console.log(`${c.green}✓${c.reset} Removed context for: qmd://${detected.collectionName}/${detected.relativePath}`);
  717. }
  718. function getDocument(filename: string, fromLine?: number, maxLines?: number, lineNumbers?: boolean): void {
  719. const db = getDb();
  720. // Parse :linenum suffix from filename (e.g., "file.md:100")
  721. let inputPath = filename;
  722. const colonMatch = inputPath.match(/:(\d+)$/);
  723. if (colonMatch && !fromLine) {
  724. const matched = colonMatch[1];
  725. if (matched) {
  726. fromLine = parseInt(matched, 10);
  727. inputPath = inputPath.slice(0, -colonMatch[0].length);
  728. }
  729. }
  730. // Handle docid lookup (#abc123, abc123, "#abc123", "abc123", etc.)
  731. if (isDocid(inputPath)) {
  732. const docidMatch = findDocumentByDocid(db, inputPath);
  733. if (docidMatch) {
  734. inputPath = docidMatch.filepath;
  735. } else {
  736. console.error(`Document not found: ${filename}`);
  737. closeDb();
  738. process.exit(1);
  739. }
  740. }
  741. let doc: { collectionName: string; path: string; body: string } | null = null;
  742. let virtualPath: string;
  743. // Handle virtual paths (qmd://collection/path)
  744. if (isVirtualPath(inputPath)) {
  745. const parsed = parseVirtualPath(inputPath);
  746. if (!parsed) {
  747. console.error(`Invalid virtual path: ${inputPath}`);
  748. closeDb();
  749. process.exit(1);
  750. }
  751. // Try exact match on collection + path
  752. doc = db.prepare(`
  753. SELECT d.collection as collectionName, d.path, content.doc as body
  754. FROM documents d
  755. JOIN content ON content.hash = d.hash
  756. WHERE d.collection = ? AND d.path = ? AND d.active = 1
  757. `).get(parsed.collectionName, parsed.path) as typeof doc;
  758. if (!doc) {
  759. // Try fuzzy match by path ending
  760. doc = db.prepare(`
  761. SELECT d.collection as collectionName, d.path, content.doc as body
  762. FROM documents d
  763. JOIN content ON content.hash = d.hash
  764. WHERE d.collection = ? AND d.path LIKE ? AND d.active = 1
  765. LIMIT 1
  766. `).get(parsed.collectionName, `%${parsed.path}`) as typeof doc;
  767. }
  768. virtualPath = inputPath;
  769. } else {
  770. // Try to interpret as collection/path format first (before filesystem path)
  771. // If path is relative (no / or ~ prefix), check if first component is a collection name
  772. if (!inputPath.startsWith('/') && !inputPath.startsWith('~')) {
  773. const parts = inputPath.split('/');
  774. if (parts.length >= 2) {
  775. const possibleCollection = parts[0];
  776. const possiblePath = parts.slice(1).join('/');
  777. // Check if this collection exists
  778. const collExists = possibleCollection ? db.prepare(`
  779. SELECT 1 FROM documents WHERE collection = ? AND active = 1 LIMIT 1
  780. `).get(possibleCollection) : null;
  781. if (collExists) {
  782. // Try exact match on collection + path
  783. doc = db.prepare(`
  784. SELECT d.collection as collectionName, d.path, content.doc as body
  785. FROM documents d
  786. JOIN content ON content.hash = d.hash
  787. WHERE d.collection = ? AND d.path = ? AND d.active = 1
  788. `).get(possibleCollection || "", possiblePath || "") as { collectionName: string; path: string; body: string } | null;
  789. if (!doc) {
  790. // Try fuzzy match by path ending
  791. doc = db.prepare(`
  792. SELECT d.collection as collectionName, d.path, content.doc as body
  793. FROM documents d
  794. JOIN content ON content.hash = d.hash
  795. WHERE d.collection = ? AND d.path LIKE ? AND d.active = 1
  796. LIMIT 1
  797. `).get(possibleCollection || "", `%${possiblePath}`) as { collectionName: string; path: string; body: string } | null;
  798. }
  799. if (doc) {
  800. virtualPath = buildVirtualPath(doc.collectionName, doc.path);
  801. // Skip the filesystem path handling below
  802. }
  803. }
  804. }
  805. }
  806. // If not found as collection/path, handle as filesystem paths
  807. if (!doc) {
  808. let fsPath = inputPath;
  809. // Expand ~ to home directory
  810. if (fsPath.startsWith('~/')) {
  811. fsPath = homedir() + fsPath.slice(1);
  812. } else if (!fsPath.startsWith('/')) {
  813. // Relative path - resolve from current directory
  814. fsPath = resolve(getPwd(), fsPath);
  815. }
  816. fsPath = getRealPath(fsPath);
  817. // Try to detect which collection contains this path
  818. const detected = detectCollectionFromPath(db, fsPath);
  819. if (detected) {
  820. // Found collection - query by collection name + relative path
  821. doc = db.prepare(`
  822. SELECT d.collection as collectionName, d.path, content.doc as body
  823. FROM documents d
  824. JOIN content ON content.hash = d.hash
  825. WHERE d.collection = ? AND d.path = ? AND d.active = 1
  826. `).get(detected.collectionName, detected.relativePath) as { collectionName: string; path: string; body: string } | null;
  827. }
  828. // Fuzzy match by filename (last component of path)
  829. if (!doc) {
  830. const filename = inputPath.split('/').pop() || inputPath;
  831. doc = db.prepare(`
  832. SELECT d.collection as collectionName, d.path, content.doc as body
  833. FROM documents d
  834. JOIN content ON content.hash = d.hash
  835. WHERE d.path LIKE ? AND d.active = 1
  836. LIMIT 1
  837. `).get(`%${filename}`) as { collectionName: string; path: string; body: string } | null;
  838. }
  839. if (doc) {
  840. virtualPath = buildVirtualPath(doc.collectionName, doc.path);
  841. } else {
  842. virtualPath = inputPath;
  843. }
  844. }
  845. }
  846. // Ensure doc is not null before proceeding
  847. if (!doc) {
  848. console.error(`Document not found: ${filename}`);
  849. closeDb();
  850. process.exit(1);
  851. }
  852. // Get context for this file
  853. const context = getContextForPath(db, doc.collectionName, doc.path);
  854. let output = doc.body;
  855. const startLine = fromLine || 1;
  856. // Apply line filtering if specified
  857. if (fromLine !== undefined || maxLines !== undefined) {
  858. const lines = output.split('\n');
  859. const start = startLine - 1; // Convert to 0-indexed
  860. const end = maxLines !== undefined ? start + maxLines : lines.length;
  861. output = lines.slice(start, end).join('\n');
  862. }
  863. // Add line numbers if requested
  864. if (lineNumbers) {
  865. output = addLineNumbers(output, startLine);
  866. }
  867. // Output context header if exists
  868. if (context) {
  869. console.log(`Folder Context: ${context}\n---\n`);
  870. }
  871. console.log(output);
  872. closeDb();
  873. }
  874. // Multi-get: fetch multiple documents by glob pattern or comma-separated list
  875. function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT_MULTI_GET_MAX_BYTES, format: OutputFormat = "cli"): void {
  876. const db = getDb();
  877. // Check if it's a comma-separated list or a glob pattern
  878. const isCommaSeparated = pattern.includes(',') && !pattern.includes('*') && !pattern.includes('?') && !pattern.includes('{');
  879. let files: { filepath: string; displayPath: string; bodyLength: number; collection?: string; path?: string }[];
  880. if (isCommaSeparated) {
  881. // Comma-separated list of files (can be virtual paths or relative paths)
  882. const names = pattern.split(',').map(s => s.trim()).filter(Boolean);
  883. files = [];
  884. for (const name of names) {
  885. let doc: { virtual_path: string; body_length: number; collection: string; path: string } | null = null;
  886. // Handle virtual paths
  887. if (isVirtualPath(name)) {
  888. const parsed = parseVirtualPath(name);
  889. if (parsed) {
  890. // Try exact match on collection + path
  891. doc = db.prepare(`
  892. SELECT
  893. 'qmd://' || d.collection || '/' || d.path as virtual_path,
  894. LENGTH(content.doc) as body_length,
  895. d.collection,
  896. d.path
  897. FROM documents d
  898. JOIN content ON content.hash = d.hash
  899. WHERE d.collection = ? AND d.path = ? AND d.active = 1
  900. `).get(parsed.collectionName, parsed.path) as typeof doc;
  901. }
  902. } else {
  903. // Try exact match on path
  904. doc = db.prepare(`
  905. SELECT
  906. 'qmd://' || d.collection || '/' || d.path as virtual_path,
  907. LENGTH(content.doc) as body_length,
  908. d.collection,
  909. d.path
  910. FROM documents d
  911. JOIN content ON content.hash = d.hash
  912. WHERE d.path = ? AND d.active = 1
  913. LIMIT 1
  914. `).get(name) as { virtual_path: string; body_length: number; collection: string; path: string } | null;
  915. // Try suffix match
  916. if (!doc) {
  917. doc = db.prepare(`
  918. SELECT
  919. 'qmd://' || d.collection || '/' || d.path as virtual_path,
  920. LENGTH(content.doc) as body_length,
  921. d.collection,
  922. d.path
  923. FROM documents d
  924. JOIN content ON content.hash = d.hash
  925. WHERE d.path LIKE ? AND d.active = 1
  926. LIMIT 1
  927. `).get(`%${name}`) as { virtual_path: string; body_length: number; collection: string; path: string } | null;
  928. }
  929. }
  930. if (doc) {
  931. files.push({
  932. filepath: doc.virtual_path,
  933. displayPath: doc.virtual_path,
  934. bodyLength: doc.body_length,
  935. collection: doc.collection,
  936. path: doc.path
  937. });
  938. } else {
  939. console.error(`File not found: ${name}`);
  940. }
  941. }
  942. } else {
  943. // Glob pattern - matchFilesByGlob now returns virtual paths
  944. files = matchFilesByGlob(db, pattern).map(f => ({
  945. ...f,
  946. collection: undefined, // Will be fetched later if needed
  947. path: undefined
  948. }));
  949. if (files.length === 0) {
  950. console.error(`No files matched pattern: ${pattern}`);
  951. closeDb();
  952. process.exit(1);
  953. }
  954. }
  955. // Collect results for structured output
  956. const results: { file: string; displayPath: string; title: string; body: string; context: string | null; skipped: boolean; skipReason?: string }[] = [];
  957. for (const file of files) {
  958. // Parse virtual path to get collection info if not already available
  959. let collection = file.collection;
  960. let path = file.path;
  961. if (!collection || !path) {
  962. const parsed = parseVirtualPath(file.filepath);
  963. if (parsed) {
  964. collection = parsed.collectionName;
  965. path = parsed.path;
  966. }
  967. }
  968. // Get context using collection-scoped function
  969. const context = collection && path ? getContextForPath(db, collection, path) : null;
  970. // Check size limit
  971. if (file.bodyLength > maxBytes) {
  972. results.push({
  973. file: file.filepath,
  974. displayPath: file.displayPath,
  975. title: file.displayPath.split('/').pop() || file.displayPath,
  976. body: "",
  977. context,
  978. skipped: true,
  979. skipReason: `File too large (${Math.round(file.bodyLength / 1024)}KB > ${Math.round(maxBytes / 1024)}KB). Use 'qmd get ${file.displayPath}' to retrieve.`,
  980. });
  981. continue;
  982. }
  983. // Fetch document content using collection and path
  984. if (!collection || !path) continue;
  985. const doc = db.prepare(`
  986. SELECT content.doc as body, d.title
  987. FROM documents d
  988. JOIN content ON content.hash = d.hash
  989. WHERE d.collection = ? AND d.path = ? AND d.active = 1
  990. `).get(collection, path) as { body: string; title: string } | null;
  991. if (!doc) continue;
  992. let body = doc.body;
  993. // Apply line limit if specified
  994. if (maxLines !== undefined) {
  995. const lines = body.split('\n');
  996. body = lines.slice(0, maxLines).join('\n');
  997. if (lines.length > maxLines) {
  998. body += `\n\n[... truncated ${lines.length - maxLines} more lines]`;
  999. }
  1000. }
  1001. results.push({
  1002. file: file.filepath,
  1003. displayPath: file.displayPath,
  1004. title: doc.title || file.displayPath.split('/').pop() || file.displayPath,
  1005. body,
  1006. context,
  1007. skipped: false,
  1008. });
  1009. }
  1010. closeDb();
  1011. // Output based on format
  1012. if (format === "json") {
  1013. const output = results.map(r => ({
  1014. file: r.displayPath,
  1015. title: r.title,
  1016. ...(r.context && { context: r.context }),
  1017. ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
  1018. }));
  1019. console.log(JSON.stringify(output, null, 2));
  1020. } else if (format === "csv") {
  1021. const escapeField = (val: string | null | undefined): string => {
  1022. if (val === null || val === undefined) return "";
  1023. const str = String(val);
  1024. if (str.includes(",") || str.includes('"') || str.includes("\n")) {
  1025. return `"${str.replace(/"/g, '""')}"`;
  1026. }
  1027. return str;
  1028. };
  1029. console.log("file,title,context,skipped,body");
  1030. for (const r of results) {
  1031. console.log([r.displayPath, r.title, r.context, r.skipped ? "true" : "false", r.skipped ? r.skipReason : r.body].map(escapeField).join(","));
  1032. }
  1033. } else if (format === "files") {
  1034. for (const r of results) {
  1035. const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
  1036. const status = r.skipped ? "[SKIPPED]" : "";
  1037. console.log(`${r.displayPath}${ctx}${status ? `,${status}` : ""}`);
  1038. }
  1039. } else if (format === "md") {
  1040. for (const r of results) {
  1041. console.log(`## ${r.displayPath}\n`);
  1042. if (r.title && r.title !== r.displayPath) console.log(`**Title:** ${r.title}\n`);
  1043. if (r.context) console.log(`**Context:** ${r.context}\n`);
  1044. if (r.skipped) {
  1045. console.log(`> ${r.skipReason}\n`);
  1046. } else {
  1047. console.log("```");
  1048. console.log(r.body);
  1049. console.log("```\n");
  1050. }
  1051. }
  1052. } else if (format === "xml") {
  1053. console.log('<?xml version="1.0" encoding="UTF-8"?>');
  1054. console.log("<documents>");
  1055. for (const r of results) {
  1056. console.log(" <document>");
  1057. console.log(` <file>${escapeXml(r.displayPath)}</file>`);
  1058. console.log(` <title>${escapeXml(r.title)}</title>`);
  1059. if (r.context) console.log(` <context>${escapeXml(r.context)}</context>`);
  1060. if (r.skipped) {
  1061. console.log(` <skipped>true</skipped>`);
  1062. console.log(` <reason>${escapeXml(r.skipReason || "")}</reason>`);
  1063. } else {
  1064. console.log(` <body>${escapeXml(r.body)}</body>`);
  1065. }
  1066. console.log(" </document>");
  1067. }
  1068. console.log("</documents>");
  1069. } else {
  1070. // CLI format (default)
  1071. for (const r of results) {
  1072. console.log(`\n${'='.repeat(60)}`);
  1073. console.log(`File: ${r.displayPath}`);
  1074. console.log(`${'='.repeat(60)}\n`);
  1075. if (r.skipped) {
  1076. console.log(`[SKIPPED: ${r.skipReason}]`);
  1077. continue;
  1078. }
  1079. if (r.context) {
  1080. console.log(`Folder Context: ${r.context}\n---\n`);
  1081. }
  1082. console.log(r.body);
  1083. }
  1084. }
  1085. }
  1086. // List files in virtual file tree
  1087. function listFiles(pathArg?: string): void {
  1088. const db = getDb();
  1089. if (!pathArg) {
  1090. // No argument - list all collections
  1091. const yamlCollections = yamlListCollections();
  1092. if (yamlCollections.length === 0) {
  1093. console.log("No collections found. Run 'qmd collection add .' to index files.");
  1094. closeDb();
  1095. return;
  1096. }
  1097. // Get file counts from database for each collection
  1098. const collections = yamlCollections.map(coll => {
  1099. const stats = db.prepare(`
  1100. SELECT COUNT(*) as file_count
  1101. FROM documents d
  1102. WHERE d.collection = ? AND d.active = 1
  1103. `).get(coll.name) as { file_count: number } | null;
  1104. return {
  1105. name: coll.name,
  1106. file_count: stats?.file_count || 0
  1107. };
  1108. });
  1109. console.log(`${c.bold}Collections:${c.reset}\n`);
  1110. for (const coll of collections) {
  1111. console.log(` ${c.dim}qmd://${c.reset}${c.cyan}${coll.name}/${c.reset} ${c.dim}(${coll.file_count} files)${c.reset}`);
  1112. }
  1113. closeDb();
  1114. return;
  1115. }
  1116. // Parse the path argument
  1117. let collectionName: string;
  1118. let pathPrefix: string | null = null;
  1119. if (pathArg.startsWith('qmd://')) {
  1120. // Virtual path format: qmd://collection/path
  1121. const parsed = parseVirtualPath(pathArg);
  1122. if (!parsed) {
  1123. console.error(`Invalid virtual path: ${pathArg}`);
  1124. closeDb();
  1125. process.exit(1);
  1126. }
  1127. collectionName = parsed.collectionName;
  1128. pathPrefix = parsed.path;
  1129. } else {
  1130. // Just collection name or collection/path
  1131. const parts = pathArg.split('/');
  1132. collectionName = parts[0] || '';
  1133. if (parts.length > 1) {
  1134. pathPrefix = parts.slice(1).join('/');
  1135. }
  1136. }
  1137. // Get the collection
  1138. const coll = getCollectionFromYaml(collectionName);
  1139. if (!coll) {
  1140. console.error(`Collection not found: ${collectionName}`);
  1141. console.error(`Run 'qmd ls' to see available collections.`);
  1142. closeDb();
  1143. process.exit(1);
  1144. }
  1145. // List files in the collection with size and modification time
  1146. let query: string;
  1147. let params: any[];
  1148. if (pathPrefix) {
  1149. // List files under a specific path
  1150. query = `
  1151. SELECT d.path, d.title, d.modified_at, LENGTH(ct.doc) as size
  1152. FROM documents d
  1153. JOIN content ct ON d.hash = ct.hash
  1154. WHERE d.collection = ? AND d.path LIKE ? AND d.active = 1
  1155. ORDER BY d.path
  1156. `;
  1157. params = [coll.name, `${pathPrefix}%`];
  1158. } else {
  1159. // List all files in the collection
  1160. query = `
  1161. SELECT d.path, d.title, d.modified_at, LENGTH(ct.doc) as size
  1162. FROM documents d
  1163. JOIN content ct ON d.hash = ct.hash
  1164. WHERE d.collection = ? AND d.active = 1
  1165. ORDER BY d.path
  1166. `;
  1167. params = [coll.name];
  1168. }
  1169. const files = db.prepare(query).all(...params) as { path: string; title: string; modified_at: string; size: number }[];
  1170. if (files.length === 0) {
  1171. if (pathPrefix) {
  1172. console.log(`No files found under qmd://${collectionName}/${pathPrefix}`);
  1173. } else {
  1174. console.log(`No files found in collection: ${collectionName}`);
  1175. }
  1176. closeDb();
  1177. return;
  1178. }
  1179. // Calculate max widths for alignment
  1180. const maxSize = Math.max(...files.map(f => formatBytes(f.size).length));
  1181. // Output in ls -l style
  1182. for (const file of files) {
  1183. const sizeStr = formatBytes(file.size).padStart(maxSize);
  1184. const date = new Date(file.modified_at);
  1185. const timeStr = formatLsTime(date);
  1186. // Dim the qmd:// prefix, highlight the filename
  1187. console.log(`${sizeStr} ${timeStr} ${c.dim}qmd://${collectionName}/${c.reset}${c.cyan}${file.path}${c.reset}`);
  1188. }
  1189. closeDb();
  1190. }
  1191. // Format date/time like ls -l
  1192. function formatLsTime(date: Date): string {
  1193. const now = new Date();
  1194. const sixMonthsAgo = new Date(now.getTime() - 6 * 30 * 24 * 60 * 60 * 1000);
  1195. const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
  1196. const month = months[date.getMonth()];
  1197. const day = date.getDate().toString().padStart(2, ' ');
  1198. // If file is older than 6 months, show year instead of time
  1199. if (date < sixMonthsAgo) {
  1200. const year = date.getFullYear();
  1201. return `${month} ${day} ${year}`;
  1202. } else {
  1203. const hours = date.getHours().toString().padStart(2, '0');
  1204. const minutes = date.getMinutes().toString().padStart(2, '0');
  1205. return `${month} ${day} ${hours}:${minutes}`;
  1206. }
  1207. }
  1208. // Collection management commands
  1209. function collectionList(): void {
  1210. const db = getDb();
  1211. const collections = listCollections(db);
  1212. if (collections.length === 0) {
  1213. console.log("No collections found. Run 'qmd collection add .' to create one.");
  1214. closeDb();
  1215. return;
  1216. }
  1217. console.log(`${c.bold}Collections (${collections.length}):${c.reset}\n`);
  1218. for (const coll of collections) {
  1219. const updatedAt = coll.last_modified ? new Date(coll.last_modified) : new Date();
  1220. const timeAgo = formatTimeAgo(updatedAt);
  1221. // Get YAML config to check includeByDefault
  1222. const yamlColl = getCollectionFromYaml(coll.name);
  1223. const excluded = yamlColl?.includeByDefault === false;
  1224. const excludeTag = excluded ? ` ${c.yellow}[excluded]${c.reset}` : '';
  1225. console.log(`${c.cyan}${coll.name}${c.reset} ${c.dim}(qmd://${coll.name}/)${c.reset}${excludeTag}`);
  1226. console.log(` ${c.dim}Pattern:${c.reset} ${coll.glob_pattern}`);
  1227. if (yamlColl?.ignore?.length) {
  1228. console.log(` ${c.dim}Ignore:${c.reset} ${yamlColl.ignore.join(', ')}`);
  1229. }
  1230. console.log(` ${c.dim}Files:${c.reset} ${coll.active_count}`);
  1231. console.log(` ${c.dim}Updated:${c.reset} ${timeAgo}`);
  1232. console.log();
  1233. }
  1234. closeDb();
  1235. }
  1236. async function collectionAdd(pwd: string, globPattern: string, name?: string): Promise<void> {
  1237. // If name not provided, generate from pwd basename
  1238. let collName = name;
  1239. if (!collName) {
  1240. const parts = pwd.split('/').filter(Boolean);
  1241. collName = parts[parts.length - 1] || 'root';
  1242. }
  1243. // Check if collection with this name already exists in YAML
  1244. const existing = getCollectionFromYaml(collName);
  1245. if (existing) {
  1246. console.error(`${c.yellow}Collection '${collName}' already exists.${c.reset}`);
  1247. console.error(`Use a different name with --name <name>`);
  1248. process.exit(1);
  1249. }
  1250. // Check if a collection with this pwd+glob already exists in YAML
  1251. const allCollections = yamlListCollections();
  1252. const existingPwdGlob = allCollections.find(c => c.path === pwd && c.pattern === globPattern);
  1253. if (existingPwdGlob) {
  1254. console.error(`${c.yellow}A collection already exists for this path and pattern:${c.reset}`);
  1255. console.error(` Name: ${existingPwdGlob.name} (qmd://${existingPwdGlob.name}/)`);
  1256. console.error(` Pattern: ${globPattern}`);
  1257. console.error(`\nUse 'qmd update' to re-index it, or remove it first with 'qmd collection remove ${existingPwdGlob.name}'`);
  1258. process.exit(1);
  1259. }
  1260. // Add to YAML config + sync to SQLite
  1261. const { addCollection } = await import("../collections.js");
  1262. addCollection(collName, pwd, globPattern);
  1263. resyncConfig();
  1264. // Create the collection and index files
  1265. console.log(`Creating collection '${collName}'...`);
  1266. const newColl = getCollectionFromYaml(collName);
  1267. await indexFiles(pwd, globPattern, collName, false, newColl?.ignore);
  1268. console.log(`${c.green}✓${c.reset} Collection '${collName}' created successfully`);
  1269. }
  1270. function collectionRemove(name: string): void {
  1271. // Check if collection exists in YAML
  1272. const coll = getCollectionFromYaml(name);
  1273. if (!coll) {
  1274. console.error(`${c.yellow}Collection not found: ${name}${c.reset}`);
  1275. console.error(`Run 'qmd collection list' to see available collections.`);
  1276. process.exit(1);
  1277. }
  1278. const db = getDb();
  1279. const result = removeCollection(db, name);
  1280. // Also remove from YAML config
  1281. yamlRemoveCollectionFn(name);
  1282. closeDb();
  1283. console.log(`${c.green}✓${c.reset} Removed collection '${name}'`);
  1284. console.log(` Deleted ${result.deletedDocs} documents`);
  1285. if (result.cleanedHashes > 0) {
  1286. console.log(` Cleaned up ${result.cleanedHashes} orphaned content hashes`);
  1287. }
  1288. }
  1289. function collectionRename(oldName: string, newName: string): void {
  1290. // Check if old collection exists in YAML
  1291. const coll = getCollectionFromYaml(oldName);
  1292. if (!coll) {
  1293. console.error(`${c.yellow}Collection not found: ${oldName}${c.reset}`);
  1294. console.error(`Run 'qmd collection list' to see available collections.`);
  1295. process.exit(1);
  1296. }
  1297. // Check if new name already exists in YAML
  1298. const existing = getCollectionFromYaml(newName);
  1299. if (existing) {
  1300. console.error(`${c.yellow}Collection name already exists: ${newName}${c.reset}`);
  1301. console.error(`Choose a different name or remove the existing collection first.`);
  1302. process.exit(1);
  1303. }
  1304. const db = getDb();
  1305. renameCollection(db, oldName, newName);
  1306. // Also rename in YAML config
  1307. yamlRenameCollectionFn(oldName, newName);
  1308. closeDb();
  1309. console.log(`${c.green}✓${c.reset} Renamed collection '${oldName}' to '${newName}'`);
  1310. console.log(` Virtual paths updated: ${c.cyan}qmd://${oldName}/${c.reset} → ${c.cyan}qmd://${newName}/${c.reset}`);
  1311. }
  1312. async function indexFiles(pwd?: string, globPattern: string = DEFAULT_GLOB, collectionName?: string, suppressEmbedNotice: boolean = false, ignorePatterns?: string[]): Promise<void> {
  1313. const db = getDb();
  1314. const resolvedPwd = pwd || getPwd();
  1315. const now = new Date().toISOString();
  1316. const excludeDirs = ["node_modules", ".git", ".cache", "vendor", "dist", "build"];
  1317. // Clear Ollama cache on index
  1318. clearCache(db);
  1319. // Collection name must be provided (from YAML)
  1320. if (!collectionName) {
  1321. throw new Error("Collection name is required. Collections must be defined in ~/.config/qmd/index.yml");
  1322. }
  1323. console.log(`Collection: ${resolvedPwd} (${globPattern})`);
  1324. progress.indeterminate();
  1325. const allIgnore = [
  1326. ...excludeDirs.map(d => `**/${d}/**`),
  1327. ...(ignorePatterns || []),
  1328. ];
  1329. const allFiles: string[] = await fastGlob(globPattern, {
  1330. cwd: resolvedPwd,
  1331. onlyFiles: true,
  1332. followSymbolicLinks: false,
  1333. dot: false,
  1334. ignore: allIgnore,
  1335. });
  1336. // Filter hidden files/folders (dot: false handles top-level but not nested)
  1337. const files = allFiles.filter(file => {
  1338. const parts = file.split("/");
  1339. return !parts.some(part => part.startsWith("."));
  1340. });
  1341. const total = files.length;
  1342. const hasNoFiles = total === 0;
  1343. if (hasNoFiles) {
  1344. progress.clear();
  1345. console.log("No files found matching pattern.");
  1346. // Continue so the deactivation pass can mark previously indexed docs as inactive.
  1347. }
  1348. let indexed = 0, updated = 0, unchanged = 0, processed = 0;
  1349. const seenPaths = new Set<string>();
  1350. const startTime = Date.now();
  1351. for (const relativeFile of files) {
  1352. const filepath = getRealPath(resolve(resolvedPwd, relativeFile));
  1353. const path = handelize(relativeFile); // Normalize path for token-friendliness
  1354. seenPaths.add(path);
  1355. let content: string;
  1356. try {
  1357. content = readFileSync(filepath, "utf-8");
  1358. } catch (err: any) {
  1359. // Skip files that can't be read (e.g. iCloud evicted files returning EAGAIN)
  1360. processed++;
  1361. progress.set((processed / total) * 100);
  1362. continue;
  1363. }
  1364. // Skip empty files - nothing useful to index
  1365. if (!content.trim()) {
  1366. processed++;
  1367. continue;
  1368. }
  1369. const hash = await hashContent(content);
  1370. const title = extractTitle(content, relativeFile);
  1371. // Check if document exists in this collection with this path
  1372. const existing = findActiveDocument(db, collectionName, path);
  1373. if (existing) {
  1374. if (existing.hash === hash) {
  1375. // Hash unchanged, but check if title needs updating
  1376. if (existing.title !== title) {
  1377. updateDocumentTitle(db, existing.id, title, now);
  1378. updated++;
  1379. } else {
  1380. unchanged++;
  1381. }
  1382. } else {
  1383. // Content changed - insert new content hash and update document
  1384. insertContent(db, hash, content, now);
  1385. const stat = statSync(filepath);
  1386. updateDocument(db, existing.id, title, hash,
  1387. stat ? new Date(stat.mtime).toISOString() : now);
  1388. updated++;
  1389. }
  1390. } else {
  1391. // New document - insert content and document
  1392. indexed++;
  1393. insertContent(db, hash, content, now);
  1394. const stat = statSync(filepath);
  1395. insertDocument(db, collectionName, path, title, hash,
  1396. stat ? new Date(stat.birthtime).toISOString() : now,
  1397. stat ? new Date(stat.mtime).toISOString() : now);
  1398. }
  1399. processed++;
  1400. progress.set((processed / total) * 100);
  1401. const elapsed = (Date.now() - startTime) / 1000;
  1402. const rate = processed / elapsed;
  1403. const remaining = (total - processed) / rate;
  1404. const eta = processed > 2 ? ` ETA: ${formatETA(remaining)}` : "";
  1405. if (isTTY) process.stderr.write(`\rIndexing: ${processed}/${total}${eta} `);
  1406. }
  1407. // Deactivate documents in this collection that no longer exist
  1408. const allActive = getActiveDocumentPaths(db, collectionName);
  1409. let removed = 0;
  1410. for (const path of allActive) {
  1411. if (!seenPaths.has(path)) {
  1412. deactivateDocument(db, collectionName, path);
  1413. removed++;
  1414. }
  1415. }
  1416. // Clean up orphaned content hashes (content not referenced by any document)
  1417. const orphanedContent = cleanupOrphanedContent(db);
  1418. // Check if vector index needs updating
  1419. const needsEmbedding = getHashesNeedingEmbedding(db);
  1420. progress.clear();
  1421. console.log(`\nIndexed: ${indexed} new, ${updated} updated, ${unchanged} unchanged, ${removed} removed`);
  1422. if (orphanedContent > 0) {
  1423. console.log(`Cleaned up ${orphanedContent} orphaned content hash(es)`);
  1424. }
  1425. if (needsEmbedding > 0 && !suppressEmbedNotice) {
  1426. console.log(`\nRun 'qmd embed' to update embeddings (${needsEmbedding} unique hashes need vectors)`);
  1427. }
  1428. closeDb();
  1429. }
  1430. function renderProgressBar(percent: number, width: number = 30): string {
  1431. const filled = Math.round((percent / 100) * width);
  1432. const empty = width - filled;
  1433. const bar = "█".repeat(filled) + "░".repeat(empty);
  1434. return bar;
  1435. }
  1436. function parseEmbedBatchOption(name: string, value: unknown): number | undefined {
  1437. if (value === undefined) return undefined;
  1438. const parsed = Number(value);
  1439. if (!Number.isInteger(parsed) || parsed < 1) {
  1440. throw new Error(`${name} must be a positive integer`);
  1441. }
  1442. return parsed;
  1443. }
  1444. function parseChunkStrategy(value: unknown): ChunkStrategy | undefined {
  1445. if (value === undefined) return undefined;
  1446. const s = String(value);
  1447. if (s === "auto" || s === "regex" || s === "function") return s;
  1448. throw new Error(`--chunk-strategy must be "auto", "regex", or "function" (got "${s}")`);
  1449. }
  1450. async function vectorIndex(
  1451. model: string = DEFAULT_EMBED_MODEL_URI,
  1452. force: boolean = false,
  1453. batchOptions?: { maxDocsPerBatch?: number; maxBatchBytes?: number; chunkStrategy?: ChunkStrategy },
  1454. ): Promise<void> {
  1455. const storeInstance = getStore();
  1456. const db = storeInstance.db;
  1457. if (force) {
  1458. console.log(`${c.yellow}Force re-indexing: clearing all vectors...${c.reset}`);
  1459. }
  1460. // Check if there's work to do before starting
  1461. const hashesToEmbed = getHashesNeedingEmbedding(db);
  1462. if (hashesToEmbed === 0 && !force) {
  1463. console.log(`${c.green}✓ All content hashes already have embeddings.${c.reset}`);
  1464. closeDb();
  1465. return;
  1466. }
  1467. console.log(`${c.dim}Model: ${model}${c.reset}\n`);
  1468. if (batchOptions?.maxDocsPerBatch !== undefined || batchOptions?.maxBatchBytes !== undefined) {
  1469. const maxDocsPerBatch = batchOptions.maxDocsPerBatch ?? DEFAULT_EMBED_MAX_DOCS_PER_BATCH;
  1470. const maxBatchBytes = batchOptions.maxBatchBytes ?? DEFAULT_EMBED_MAX_BATCH_BYTES;
  1471. console.log(`${c.dim}Batch: ${maxDocsPerBatch} docs / ${formatBytes(maxBatchBytes)}${c.reset}\n`);
  1472. }
  1473. cursor.hide();
  1474. progress.indeterminate();
  1475. const startTime = Date.now();
  1476. const result = await generateEmbeddings(storeInstance, {
  1477. force,
  1478. model,
  1479. maxDocsPerBatch: batchOptions?.maxDocsPerBatch,
  1480. maxBatchBytes: batchOptions?.maxBatchBytes,
  1481. chunkStrategy: batchOptions?.chunkStrategy,
  1482. onProgress: (info) => {
  1483. if (info.totalBytes === 0) return;
  1484. const percent = (info.bytesProcessed / info.totalBytes) * 100;
  1485. progress.set(percent);
  1486. const elapsed = (Date.now() - startTime) / 1000;
  1487. const bytesPerSec = info.bytesProcessed / elapsed;
  1488. const remainingBytes = info.totalBytes - info.bytesProcessed;
  1489. const etaSec = remainingBytes / bytesPerSec;
  1490. const bar = renderProgressBar(percent);
  1491. const percentStr = percent.toFixed(0).padStart(3);
  1492. const throughput = `${formatBytes(bytesPerSec)}/s`;
  1493. const eta = elapsed > 2 ? formatETA(etaSec) : "...";
  1494. const errStr = info.errors > 0 ? ` ${c.yellow}${info.errors} err${c.reset}` : "";
  1495. if (isTTY) process.stderr.write(`\r${c.cyan}${bar}${c.reset} ${c.bold}${percentStr}%${c.reset} ${c.dim}${info.chunksEmbedded}/${info.totalChunks}${c.reset}${errStr} ${c.dim}${throughput} ETA ${eta}${c.reset} `);
  1496. },
  1497. });
  1498. progress.clear();
  1499. cursor.show();
  1500. const totalTimeSec = result.durationMs / 1000;
  1501. if (result.chunksEmbedded === 0 && result.docsProcessed === 0) {
  1502. console.log(`${c.green}✓ No non-empty documents to embed.${c.reset}`);
  1503. } else {
  1504. console.log(`\r${c.green}${renderProgressBar(100)}${c.reset} ${c.bold}100%${c.reset} `);
  1505. console.log(`\n${c.green}✓ Done!${c.reset} Embedded ${c.bold}${result.chunksEmbedded}${c.reset} chunks from ${c.bold}${result.docsProcessed}${c.reset} documents in ${c.bold}${formatETA(totalTimeSec)}${c.reset}`);
  1506. if (result.errors > 0) {
  1507. console.log(`${c.yellow}⚠ ${result.errors} chunks failed${c.reset}`);
  1508. }
  1509. }
  1510. closeDb();
  1511. }
  1512. // Sanitize a term for FTS5: remove punctuation except apostrophes
  1513. function sanitizeFTS5Term(term: string): string {
  1514. // Remove all non-alphanumeric except apostrophes (for contractions like "don't")
  1515. return term.replace(/[^\w']/g, '').trim();
  1516. }
  1517. // Build FTS5 query: phrase-aware with fallback to individual terms
  1518. function buildFTS5Query(query: string): string {
  1519. // Sanitize the full query for phrase matching
  1520. const sanitizedQuery = query.replace(/[^\w\s']/g, '').trim();
  1521. const terms = query
  1522. .split(/\s+/)
  1523. .map(sanitizeFTS5Term)
  1524. .filter(term => term.length >= 2); // Skip single chars and empty
  1525. if (terms.length === 0) return "";
  1526. if (terms.length === 1) return `"${terms[0]!.replace(/"/g, '""')}"`;
  1527. // Strategy: exact phrase OR proximity match OR individual terms
  1528. // Exact phrase matches rank highest, then close proximity, then any term
  1529. const phrase = `"${sanitizedQuery.replace(/"/g, '""')}"`;
  1530. const quotedTerms = terms.map(t => `"${t.replace(/"/g, '""')}"`);
  1531. // FTS5 NEAR syntax: NEAR(term1 term2, distance)
  1532. const nearPhrase = `NEAR(${quotedTerms.join(' ')}, 10)`;
  1533. const orTerms = quotedTerms.join(' OR ');
  1534. // Exact phrase > proximity > any term
  1535. return `(${phrase}) OR (${nearPhrase}) OR (${orTerms})`;
  1536. }
  1537. // Normalize BM25 score to 0-1 range using sigmoid
  1538. function normalizeBM25(score: number): number {
  1539. // BM25 scores are negative in SQLite (lower = better)
  1540. // Typical range: -15 (excellent) to -2 (weak match)
  1541. // Map to 0-1 where higher is better
  1542. const absScore = Math.abs(score);
  1543. // Sigmoid-ish normalization: maps ~2-15 range to ~0.1-0.95
  1544. return 1 / (1 + Math.exp(-(absScore - 5) / 3));
  1545. }
  1546. type OutputOptions = {
  1547. format: OutputFormat;
  1548. full: boolean;
  1549. limit: number;
  1550. minScore: number;
  1551. all?: boolean;
  1552. collection?: string | string[]; // Filter by collection name(s)
  1553. lineNumbers?: boolean; // Add line numbers to output
  1554. explain?: boolean; // Include retrieval score traces (query only)
  1555. context?: string; // Optional context for query expansion
  1556. candidateLimit?: number; // Max candidates to rerank (default: 40)
  1557. intent?: string; // Domain intent for disambiguation
  1558. skipRerank?: boolean; // Skip LLM reranking, use RRF scores only
  1559. chunkStrategy?: ChunkStrategy; // "auto" (default) or "regex"
  1560. };
  1561. // Highlight query terms in text (skip short words < 3 chars)
  1562. function highlightTerms(text: string, query: string): string {
  1563. if (!useColor) return text;
  1564. const terms = query.toLowerCase().split(/\s+/).filter(t => t.length >= 3);
  1565. let result = text;
  1566. for (const term of terms) {
  1567. const regex = new RegExp(`(${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
  1568. result = result.replace(regex, `${c.yellow}${c.bold}$1${c.reset}`);
  1569. }
  1570. return result;
  1571. }
  1572. // Format score with color based on value
  1573. function formatScore(score: number): string {
  1574. const pct = (score * 100).toFixed(0).padStart(3);
  1575. if (!useColor) return `${pct}%`;
  1576. if (score >= 0.7) return `${c.green}${pct}%${c.reset}`;
  1577. if (score >= 0.4) return `${c.yellow}${pct}%${c.reset}`;
  1578. return `${c.dim}${pct}%${c.reset}`;
  1579. }
  1580. function formatExplainNumber(value: number): string {
  1581. return value.toFixed(4);
  1582. }
  1583. // Shorten directory path for display - relative to $HOME (used for context paths, not documents)
  1584. function shortPath(dirpath: string): string {
  1585. const home = homedir();
  1586. if (dirpath.startsWith(home)) {
  1587. return '~' + dirpath.slice(home.length);
  1588. }
  1589. return dirpath;
  1590. }
  1591. type EmptySearchReason = "no_results" | "min_score";
  1592. // Emit format-safe empty output for search commands.
  1593. function printEmptySearchResults(format: OutputFormat, reason: EmptySearchReason = "no_results"): void {
  1594. if (format === "json") {
  1595. console.log("[]");
  1596. return;
  1597. }
  1598. if (format === "csv") {
  1599. console.log("docid,score,file,title,context,line,snippet");
  1600. return;
  1601. }
  1602. if (format === "xml") {
  1603. console.log("<results></results>");
  1604. return;
  1605. }
  1606. if (format === "md" || format === "files") {
  1607. return;
  1608. }
  1609. if (reason === "min_score") {
  1610. console.log("No results found above minimum score threshold.");
  1611. return;
  1612. }
  1613. console.log("No results found.");
  1614. }
  1615. type OutputRow = {
  1616. file: string;
  1617. displayPath: string;
  1618. title: string;
  1619. body: string;
  1620. score: number;
  1621. context?: string | null;
  1622. chunkPos?: number;
  1623. hash?: string;
  1624. docid?: string;
  1625. explain?: HybridQueryExplain;
  1626. };
  1627. const DEFAULT_EDITOR_URI_TEMPLATE = "vscode://file/{path}:{line}:{col}";
  1628. function encodePathForEditorUri(absolutePath: string): string {
  1629. return encodeURI(absolutePath)
  1630. .replace(/\?/g, "%3F")
  1631. .replace(/#/g, "%23");
  1632. }
  1633. function getEditorUriTemplate(): string {
  1634. const envTemplate = process.env.QMD_EDITOR_URI?.trim();
  1635. if (envTemplate) return envTemplate;
  1636. try {
  1637. const config = loadConfig() as unknown as {
  1638. editor_uri?: string;
  1639. editor_uri_template?: string;
  1640. editorUri?: string;
  1641. [key: string]: unknown;
  1642. };
  1643. const configTemplate = (
  1644. config.editor_uri
  1645. || config.editor_uri_template
  1646. || config.editorUri
  1647. || (typeof config["editor-uri"] === "string" ? config["editor-uri"] : undefined)
  1648. )?.trim();
  1649. if (configTemplate) return configTemplate;
  1650. } catch {
  1651. // Ignore config parsing issues and use default template.
  1652. }
  1653. return DEFAULT_EDITOR_URI_TEMPLATE;
  1654. }
  1655. export function buildEditorUri(template: string, absolutePath: string, line: number, col: number): string {
  1656. const safeLine = Number.isFinite(line) && line > 0 ? Math.floor(line) : 1;
  1657. const safeCol = Number.isFinite(col) && col > 0 ? Math.floor(col) : 1;
  1658. const encodedPath = encodePathForEditorUri(absolutePath);
  1659. return template
  1660. .replace(/\{path\}/g, encodedPath)
  1661. .replace(/\{line\}/g, String(safeLine))
  1662. .replace(/\{col\}/g, String(safeCol))
  1663. .replace(/\{column\}/g, String(safeCol));
  1664. }
  1665. export function termLink(text: string, url: string, isTTY: boolean = !!process.stdout.isTTY): string {
  1666. if (!isTTY) return text;
  1667. return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
  1668. }
  1669. function outputResults(results: OutputRow[], query: string, opts: OutputOptions): void {
  1670. const filtered = results.filter(r => r.score >= opts.minScore).slice(0, opts.limit);
  1671. if (filtered.length === 0) {
  1672. printEmptySearchResults(opts.format, "min_score");
  1673. return;
  1674. }
  1675. // Helper to create qmd:// URI from displayPath
  1676. const toQmdPath = (displayPath: string) => `qmd://${displayPath}`;
  1677. if (opts.format === "json") {
  1678. // JSON output for LLM consumption
  1679. const output = filtered.map(row => {
  1680. const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
  1681. let body = opts.full ? row.body : undefined;
  1682. let snippet = !opts.full ? extractSnippet(row.body, query, 300, row.chunkPos, undefined, opts.intent).snippet : undefined;
  1683. if (opts.lineNumbers) {
  1684. if (body) body = addLineNumbers(body);
  1685. if (snippet) snippet = addLineNumbers(snippet);
  1686. }
  1687. return {
  1688. ...(docid && { docid: `#${docid}` }),
  1689. score: Math.round(row.score * 100) / 100,
  1690. file: toQmdPath(row.displayPath),
  1691. title: row.title,
  1692. ...(row.context && { context: row.context }),
  1693. ...(body && { body }),
  1694. ...(snippet && { snippet }),
  1695. ...(opts.explain && row.explain && { explain: row.explain }),
  1696. };
  1697. });
  1698. console.log(JSON.stringify(output, null, 2));
  1699. } else if (opts.format === "files") {
  1700. // Simple docid,score,filepath,context output
  1701. for (const row of filtered) {
  1702. const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
  1703. const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
  1704. console.log(`#${docid},${row.score.toFixed(2)},${toQmdPath(row.displayPath)}${ctx}`);
  1705. }
  1706. } else if (opts.format === "cli") {
  1707. const editorUriTemplate = getEditorUriTemplate();
  1708. const linkDb = getDb();
  1709. for (let i = 0; i < filtered.length; i++) {
  1710. const row = filtered[i];
  1711. if (!row) continue;
  1712. const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent);
  1713. const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
  1714. // Line 1: filepath with docid
  1715. const virtualPath = row.file.startsWith("qmd://") ? row.file : toQmdPath(row.displayPath);
  1716. const parsed = parseVirtualPath(virtualPath);
  1717. const absolutePath = resolveVirtualPath(linkDb, virtualPath);
  1718. const legacyPath = toQmdPath(row.displayPath);
  1719. const displayPath = parsed?.path || row.displayPath;
  1720. // Only show :line if we actually found a term match in the snippet body (exclude header line).
  1721. const snippetBody = snippet.split("\n").slice(1).join("\n").toLowerCase();
  1722. const hasMatch = query.toLowerCase().split(/\s+/).some(t => t.length > 0 && snippetBody.includes(t));
  1723. const lineInfo = hasMatch ? `:${line}` : "";
  1724. const docidStr = docid ? ` ${c.dim}#${docid}${c.reset}` : "";
  1725. if (process.stdout.isTTY && absolutePath && parsed?.path) {
  1726. const linkLine = hasMatch ? line : 1;
  1727. const linkTarget = buildEditorUri(editorUriTemplate, absolutePath, linkLine, 1);
  1728. const clickable = termLink(`${displayPath}${lineInfo}`, linkTarget);
  1729. console.log(`${c.cyan}${clickable}${c.reset}${docidStr}`);
  1730. } else {
  1731. console.log(`${c.cyan}${legacyPath}${c.dim}${lineInfo}${c.reset}${docidStr}`);
  1732. }
  1733. // Line 2: Title (if available)
  1734. if (row.title) {
  1735. console.log(`${c.bold}Title: ${row.title}${c.reset}`);
  1736. }
  1737. // Line 3: Context (if available)
  1738. if (row.context) {
  1739. console.log(`${c.dim}Context: ${row.context}${c.reset}`);
  1740. }
  1741. // Line 4: Score
  1742. const score = formatScore(row.score);
  1743. console.log(`Score: ${c.bold}${score}${c.reset}`);
  1744. if (opts.explain && row.explain) {
  1745. const explain = row.explain;
  1746. const ftsScores = explain.ftsScores.length > 0
  1747. ? explain.ftsScores.map(formatExplainNumber).join(", ")
  1748. : "none";
  1749. const vecScores = explain.vectorScores.length > 0
  1750. ? explain.vectorScores.map(formatExplainNumber).join(", ")
  1751. : "none";
  1752. const contribSummary = explain.rrf.contributions
  1753. .slice()
  1754. .sort((a, b) => b.rrfContribution - a.rrfContribution)
  1755. .slice(0, 3)
  1756. .map(c => `${c.source}/${c.queryType}#${c.rank}:${formatExplainNumber(c.rrfContribution)}`)
  1757. .join(" | ");
  1758. console.log(`${c.dim}Explain: fts=[${ftsScores}] vec=[${vecScores}]${c.reset}`);
  1759. console.log(`${c.dim} RRF: total=${formatExplainNumber(explain.rrf.totalScore)} base=${formatExplainNumber(explain.rrf.baseScore)} bonus=${formatExplainNumber(explain.rrf.topRankBonus)} rank=${explain.rrf.rank}${c.reset}`);
  1760. console.log(`${c.dim} Blend: ${Math.round(explain.rrf.weight * 100)}%*${formatExplainNumber(explain.rrf.positionScore)} + ${Math.round((1 - explain.rrf.weight) * 100)}%*${formatExplainNumber(explain.rerankScore)} = ${formatExplainNumber(explain.blendedScore)}${c.reset}`);
  1761. if (contribSummary.length > 0) {
  1762. console.log(`${c.dim} Top RRF contributions: ${contribSummary}${c.reset}`);
  1763. }
  1764. }
  1765. console.log();
  1766. // Snippet with highlighting (diff-style header included)
  1767. let displaySnippet = opts.lineNumbers ? addLineNumbers(snippet, line) : snippet;
  1768. const highlighted = highlightTerms(displaySnippet, query);
  1769. console.log(highlighted);
  1770. // Double empty line between results
  1771. if (i < filtered.length - 1) console.log('\n');
  1772. }
  1773. } else if (opts.format === "md") {
  1774. for (let i = 0; i < filtered.length; i++) {
  1775. const row = filtered[i];
  1776. if (!row) continue;
  1777. const heading = row.title || row.displayPath;
  1778. const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : undefined);
  1779. let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent).snippet;
  1780. if (opts.lineNumbers) {
  1781. content = addLineNumbers(content);
  1782. }
  1783. const docidLine = docid ? `**docid:** \`#${docid}\`\n` : "";
  1784. const contextLine = row.context ? `**context:** ${row.context}\n` : "";
  1785. console.log(`---\n# ${heading}\n${docidLine}${contextLine}\n${content}\n`);
  1786. }
  1787. } else if (opts.format === "xml") {
  1788. for (const row of filtered) {
  1789. const titleAttr = row.title ? ` title="${row.title.replace(/"/g, '&quot;')}"` : "";
  1790. const contextAttr = row.context ? ` context="${row.context.replace(/"/g, '&quot;')}"` : "";
  1791. const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
  1792. let content = opts.full ? row.body : extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent).snippet;
  1793. if (opts.lineNumbers) {
  1794. content = addLineNumbers(content);
  1795. }
  1796. console.log(`<file docid="#${docid}" name="${toQmdPath(row.displayPath)}"${titleAttr}${contextAttr}>\n${content}\n</file>\n`);
  1797. }
  1798. } else {
  1799. // CSV format
  1800. console.log("docid,score,file,title,context,line,snippet");
  1801. for (const row of filtered) {
  1802. const { line, snippet } = extractSnippet(row.body, query, 500, row.chunkPos, undefined, opts.intent);
  1803. let content = opts.full ? row.body : snippet;
  1804. if (opts.lineNumbers) {
  1805. content = addLineNumbers(content, line);
  1806. }
  1807. const docid = row.docid || (row.hash ? row.hash.slice(0, 6) : "");
  1808. const snippetText = content || "";
  1809. console.log(`#${docid},${row.score.toFixed(4)},${escapeCSV(toQmdPath(row.displayPath))},${escapeCSV(row.title || "")},${escapeCSV(row.context || "")},${line},${escapeCSV(snippetText)}`);
  1810. }
  1811. }
  1812. }
  1813. // Resolve -c collection filter: supports single string, array, or undefined.
  1814. // Returns validated collection names (exits on unknown collection).
  1815. function resolveCollectionFilter(raw: string | string[] | undefined, useDefaults: boolean = false): string[] {
  1816. // If no filter specified and useDefaults is true, use default collections
  1817. if (!raw && useDefaults) {
  1818. return getDefaultCollectionNames();
  1819. }
  1820. if (!raw) return [];
  1821. const names = Array.isArray(raw) ? raw : [raw];
  1822. const validated: string[] = [];
  1823. for (const name of names) {
  1824. const coll = getCollectionFromYaml(name);
  1825. if (!coll) {
  1826. console.error(`Collection not found: ${name}`);
  1827. closeDb();
  1828. process.exit(1);
  1829. }
  1830. validated.push(name);
  1831. }
  1832. return validated;
  1833. }
  1834. // Post-filter results to only include files from specified collections.
  1835. function filterByCollections<T extends { filepath?: string; file?: string }>(results: T[], collectionNames: string[]): T[] {
  1836. if (collectionNames.length <= 1) return results;
  1837. const prefixes = collectionNames.map(n => `qmd://${n}/`);
  1838. return results.filter(r => {
  1839. const path = r.filepath || r.file || '';
  1840. return prefixes.some(p => path.startsWith(p));
  1841. });
  1842. }
  1843. /**
  1844. * Parse structured search query syntax.
  1845. * Lines starting with lex:, vec:, or hyde: are routed directly.
  1846. * Plain lines without prefix go through query expansion.
  1847. *
  1848. * Returns null if this is a plain query (single line, no prefix).
  1849. * Returns ExpandedQuery[] if structured syntax detected.
  1850. * Throws if multiple plain lines (ambiguous).
  1851. *
  1852. * Examples:
  1853. * "CAP theorem" -> null (plain query, use expansion)
  1854. * "lex: CAP theorem" -> [{ type: 'lex', query: 'CAP theorem' }]
  1855. * "lex: CAP\nvec: consistency" -> [{ type: 'lex', ... }, { type: 'vec', ... }]
  1856. * "CAP\nconsistency" -> throws (multiple plain lines)
  1857. */
  1858. interface ParsedStructuredQuery {
  1859. searches: ExpandedQuery[];
  1860. intent?: string;
  1861. }
  1862. function parseStructuredQuery(query: string): ParsedStructuredQuery | null {
  1863. const rawLines = query.split('\n').map((line, idx) => ({
  1864. raw: line,
  1865. trimmed: line.trim(),
  1866. number: idx + 1,
  1867. })).filter(line => line.trimmed.length > 0);
  1868. if (rawLines.length === 0) return null;
  1869. const prefixRe = /^(lex|vec|hyde):\s*/i;
  1870. const expandRe = /^expand:\s*/i;
  1871. const intentRe = /^intent:\s*/i;
  1872. const typed: ExpandedQuery[] = [];
  1873. let intent: string | undefined;
  1874. for (const line of rawLines) {
  1875. if (expandRe.test(line.trimmed)) {
  1876. if (rawLines.length > 1) {
  1877. throw new Error(`Line ${line.number} starts with expand:, but query documents cannot mix expand with typed lines. Submit a single expand query instead.`);
  1878. }
  1879. const text = line.trimmed.replace(expandRe, '').trim();
  1880. if (!text) {
  1881. throw new Error('expand: query must include text.');
  1882. }
  1883. return null; // treat as standalone expand query
  1884. }
  1885. // Parse intent: lines
  1886. if (intentRe.test(line.trimmed)) {
  1887. if (intent !== undefined) {
  1888. throw new Error(`Line ${line.number}: only one intent: line is allowed per query document.`);
  1889. }
  1890. const text = line.trimmed.replace(intentRe, '').trim();
  1891. if (!text) {
  1892. throw new Error(`Line ${line.number}: intent: must include text.`);
  1893. }
  1894. intent = text;
  1895. continue;
  1896. }
  1897. const match = line.trimmed.match(prefixRe);
  1898. if (match) {
  1899. const type = match[1]!.toLowerCase() as 'lex' | 'vec' | 'hyde';
  1900. const text = line.trimmed.slice(match[0].length).trim();
  1901. if (!text) {
  1902. throw new Error(`Line ${line.number} (${type}:) must include text.`);
  1903. }
  1904. if (/\r|\n/.test(text)) {
  1905. throw new Error(`Line ${line.number} (${type}:) contains a newline. Keep each query on a single line.`);
  1906. }
  1907. typed.push({ type, query: text, line: line.number });
  1908. continue;
  1909. }
  1910. if (rawLines.length === 1) {
  1911. // Single plain line -> implicit expand
  1912. return null;
  1913. }
  1914. throw new Error(`Line ${line.number} is missing a lex:/vec:/hyde:/intent: prefix. Each line in a query document must start with one.`);
  1915. }
  1916. // intent: alone is not a valid query — must have at least one search
  1917. if (intent && typed.length === 0) {
  1918. throw new Error('intent: cannot appear alone. Add at least one lex:, vec:, or hyde: line.');
  1919. }
  1920. return typed.length > 0 ? { searches: typed, intent } : null;
  1921. }
  1922. function search(query: string, opts: OutputOptions): void {
  1923. const db = getDb();
  1924. // Validate collection filter (supports multiple -c flags)
  1925. // Use default collections if none specified
  1926. const collectionNames = resolveCollectionFilter(opts.collection, true);
  1927. const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
  1928. // Use large limit for --all, otherwise fetch more than needed and let outputResults filter
  1929. const fetchLimit = opts.all ? 100000 : Math.max(50, opts.limit * 2);
  1930. const results = filterByCollections(
  1931. searchFTS(db, query, fetchLimit, singleCollection),
  1932. collectionNames
  1933. );
  1934. // Add context to results
  1935. const resultsWithContext = results.map(r => ({
  1936. file: r.filepath,
  1937. displayPath: r.displayPath,
  1938. title: r.title,
  1939. body: r.body || "",
  1940. score: r.score,
  1941. context: getContextForFile(db, r.filepath),
  1942. hash: r.hash,
  1943. docid: r.docid,
  1944. }));
  1945. closeDb();
  1946. if (resultsWithContext.length === 0) {
  1947. printEmptySearchResults(opts.format);
  1948. return;
  1949. }
  1950. outputResults(resultsWithContext, query, opts);
  1951. }
  1952. // Log query expansion as a tree to stderr (CLI progress feedback)
  1953. function logExpansionTree(originalQuery: string, expanded: ExpandedQuery[]): void {
  1954. const lines: string[] = [];
  1955. lines.push(`${c.dim}├─ ${originalQuery}${c.reset}`);
  1956. for (const q of expanded) {
  1957. let preview = q.query.replace(/\n/g, ' ');
  1958. if (preview.length > 72) preview = preview.substring(0, 69) + '...';
  1959. lines.push(`${c.dim}├─ ${q.type}: ${preview}${c.reset}`);
  1960. }
  1961. if (lines.length > 0) {
  1962. lines[lines.length - 1] = lines[lines.length - 1]!.replace('├─', '└─');
  1963. }
  1964. for (const line of lines) process.stderr.write(line + '\n');
  1965. }
  1966. async function vectorSearch(query: string, opts: OutputOptions, _model: string = DEFAULT_EMBED_MODEL): Promise<void> {
  1967. const store = getStore();
  1968. // Validate collection filter (supports multiple -c flags)
  1969. // Use default collections if none specified
  1970. const collectionNames = resolveCollectionFilter(opts.collection, true);
  1971. const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
  1972. checkIndexHealth(store.db);
  1973. await withLLMSession(async () => {
  1974. let results = await vectorSearchQuery(store, query, {
  1975. collection: singleCollection,
  1976. limit: opts.all ? 500 : (opts.limit || 10),
  1977. minScore: opts.minScore || 0.3,
  1978. intent: opts.intent,
  1979. hooks: {
  1980. onExpand: (original, expanded) => {
  1981. logExpansionTree(original, expanded);
  1982. process.stderr.write(`${c.dim}Searching ${expanded.length + 1} vector queries...${c.reset}\n`);
  1983. },
  1984. },
  1985. });
  1986. // Post-filter for multi-collection
  1987. if (collectionNames.length > 1) {
  1988. results = results.filter(r => {
  1989. const prefixes = collectionNames.map(n => `qmd://${n}/`);
  1990. return prefixes.some(p => r.file.startsWith(p));
  1991. });
  1992. }
  1993. closeDb();
  1994. if (results.length === 0) {
  1995. printEmptySearchResults(opts.format);
  1996. return;
  1997. }
  1998. outputResults(results.map(r => ({
  1999. file: r.file,
  2000. displayPath: r.displayPath,
  2001. title: r.title,
  2002. body: r.body,
  2003. score: r.score,
  2004. context: r.context,
  2005. docid: r.docid,
  2006. })), query, { ...opts, limit: results.length });
  2007. }, { maxDuration: 10 * 60 * 1000, name: 'vectorSearch' });
  2008. }
  2009. async function querySearch(query: string, opts: OutputOptions, _embedModel: string = DEFAULT_EMBED_MODEL, _rerankModel: string = DEFAULT_RERANK_MODEL): Promise<void> {
  2010. const store = getStore();
  2011. // Validate collection filter (supports multiple -c flags)
  2012. // Use default collections if none specified
  2013. const collectionNames = resolveCollectionFilter(opts.collection, true);
  2014. const singleCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
  2015. checkIndexHealth(store.db);
  2016. // Check for structured query syntax (lex:/vec:/hyde:/intent: prefixes)
  2017. const parsed = parseStructuredQuery(query);
  2018. // Intent can come from --intent flag or from intent: line in query document
  2019. const intent = opts.intent || parsed?.intent;
  2020. await withLLMSession(async () => {
  2021. let results;
  2022. if (parsed) {
  2023. const structuredQueries = parsed.searches;
  2024. // Structured search — user provided their own query expansions
  2025. const typeLabels = structuredQueries.map(s => s.type).join('+');
  2026. process.stderr.write(`${c.dim}Structured search: ${structuredQueries.length} queries (${typeLabels})${c.reset}\n`);
  2027. if (intent) {
  2028. process.stderr.write(`${c.dim}├─ intent: ${intent}${c.reset}\n`);
  2029. }
  2030. // Log each sub-query
  2031. for (const s of structuredQueries) {
  2032. let preview = s.query.replace(/\n/g, ' ');
  2033. if (preview.length > 72) preview = preview.substring(0, 69) + '...';
  2034. process.stderr.write(`${c.dim}├─ ${s.type}: ${preview}${c.reset}\n`);
  2035. }
  2036. process.stderr.write(`${c.dim}└─ Searching...${c.reset}\n`);
  2037. results = await structuredSearch(store, structuredQueries, {
  2038. collections: singleCollection ? [singleCollection] : undefined,
  2039. limit: opts.all ? 500 : (opts.limit || 10),
  2040. minScore: opts.minScore || 0,
  2041. candidateLimit: opts.candidateLimit,
  2042. skipRerank: opts.skipRerank,
  2043. explain: !!opts.explain,
  2044. intent,
  2045. chunkStrategy: opts.chunkStrategy,
  2046. hooks: {
  2047. onEmbedStart: (count) => {
  2048. process.stderr.write(`${c.dim}Embedding ${count} ${count === 1 ? 'query' : 'queries'}...${c.reset}`);
  2049. },
  2050. onEmbedDone: (ms) => {
  2051. process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
  2052. },
  2053. onRerankStart: (chunkCount) => {
  2054. process.stderr.write(`${c.dim}Reranking ${chunkCount} chunks...${c.reset}`);
  2055. progress.indeterminate();
  2056. },
  2057. onRerankDone: (ms) => {
  2058. progress.clear();
  2059. process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
  2060. },
  2061. },
  2062. });
  2063. } else {
  2064. // Standard hybrid query with automatic expansion
  2065. results = await hybridQuery(store, query, {
  2066. collection: singleCollection,
  2067. limit: opts.all ? 500 : (opts.limit || 10),
  2068. minScore: opts.minScore || 0,
  2069. candidateLimit: opts.candidateLimit,
  2070. skipRerank: opts.skipRerank,
  2071. explain: !!opts.explain,
  2072. intent,
  2073. chunkStrategy: opts.chunkStrategy,
  2074. hooks: {
  2075. onStrongSignal: (score) => {
  2076. process.stderr.write(`${c.dim}Strong BM25 signal (${score.toFixed(2)}) — skipping expansion${c.reset}\n`);
  2077. },
  2078. onExpandStart: () => {
  2079. process.stderr.write(`${c.dim}Expanding query...${c.reset}`);
  2080. },
  2081. onExpand: (original, expanded, ms) => {
  2082. process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
  2083. logExpansionTree(original, expanded);
  2084. process.stderr.write(`${c.dim}Searching ${expanded.length + 1} queries...${c.reset}\n`);
  2085. },
  2086. onEmbedStart: (count) => {
  2087. process.stderr.write(`${c.dim}Embedding ${count} ${count === 1 ? 'query' : 'queries'}...${c.reset}`);
  2088. },
  2089. onEmbedDone: (ms) => {
  2090. process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
  2091. },
  2092. onRerankStart: (chunkCount) => {
  2093. process.stderr.write(`${c.dim}Reranking ${chunkCount} chunks...${c.reset}`);
  2094. progress.indeterminate();
  2095. },
  2096. onRerankDone: (ms) => {
  2097. progress.clear();
  2098. process.stderr.write(`${c.dim} (${formatMs(ms)})${c.reset}\n`);
  2099. },
  2100. },
  2101. });
  2102. }
  2103. // Post-filter for multi-collection
  2104. if (collectionNames.length > 1) {
  2105. results = results.filter(r => {
  2106. const prefixes = collectionNames.map(n => `qmd://${n}/`);
  2107. return prefixes.some(p => r.file.startsWith(p));
  2108. });
  2109. }
  2110. closeDb();
  2111. if (results.length === 0) {
  2112. printEmptySearchResults(opts.format);
  2113. return;
  2114. }
  2115. // Use first lex/vec query for output context, or original query
  2116. const structuredQueries = parsed?.searches;
  2117. const displayQuery = structuredQueries
  2118. ? (structuredQueries.find(s => s.type === 'lex')?.query || structuredQueries.find(s => s.type === 'vec')?.query || query)
  2119. : query;
  2120. // Map to CLI output format — use bestChunk for snippet display
  2121. outputResults(results.map(r => ({
  2122. file: r.file,
  2123. displayPath: r.displayPath,
  2124. title: r.title,
  2125. body: r.bestChunk,
  2126. chunkPos: r.bestChunkPos,
  2127. score: r.score,
  2128. context: r.context,
  2129. docid: r.docid,
  2130. explain: r.explain,
  2131. })), displayQuery, { ...opts, limit: results.length });
  2132. }, { maxDuration: 10 * 60 * 1000, name: 'querySearch' });
  2133. }
  2134. // Parse CLI arguments using util.parseArgs
  2135. function parseCLI() {
  2136. const { values, positionals } = parseArgs({
  2137. args: process.argv.slice(2), // Skip node and script path
  2138. options: {
  2139. // Global options
  2140. index: {
  2141. type: "string",
  2142. },
  2143. context: {
  2144. type: "string",
  2145. },
  2146. help: { type: "boolean", short: "h" },
  2147. version: { type: "boolean", short: "v" },
  2148. skill: { type: "boolean" },
  2149. global: { type: "boolean" },
  2150. yes: { type: "boolean" },
  2151. // Search options
  2152. n: { type: "string" },
  2153. "min-score": { type: "string" },
  2154. all: { type: "boolean" },
  2155. full: { type: "boolean" },
  2156. csv: { type: "boolean" },
  2157. md: { type: "boolean" },
  2158. xml: { type: "boolean" },
  2159. files: { type: "boolean" },
  2160. json: { type: "boolean" },
  2161. explain: { type: "boolean" },
  2162. collection: { type: "string", short: "c", multiple: true }, // Filter by collection(s)
  2163. // Collection options
  2164. name: { type: "string" }, // collection name
  2165. mask: { type: "string" }, // glob pattern
  2166. // Embed options
  2167. force: { type: "boolean", short: "f" },
  2168. "max-docs-per-batch": { type: "string" },
  2169. "max-batch-mb": { type: "string" },
  2170. // Update options
  2171. pull: { type: "boolean" }, // git pull before update
  2172. refresh: { type: "boolean" },
  2173. // Get options
  2174. l: { type: "string" }, // max lines
  2175. from: { type: "string" }, // start line
  2176. "max-bytes": { type: "string" }, // max bytes for multi-get
  2177. "line-numbers": { type: "boolean" }, // add line numbers to output
  2178. // Query options
  2179. "candidate-limit": { type: "string", short: "C" },
  2180. "no-rerank": { type: "boolean", default: false },
  2181. intent: { type: "string" },
  2182. // Chunking options
  2183. "chunk-strategy": { type: "string" }, // "regex" (default) or "auto" (AST for code files)
  2184. // MCP HTTP transport options
  2185. http: { type: "boolean" },
  2186. daemon: { type: "boolean" },
  2187. port: { type: "string" },
  2188. },
  2189. allowPositionals: true,
  2190. strict: false, // Allow unknown options to pass through
  2191. });
  2192. // Select index name (default: "index")
  2193. const indexName = values.index as string | undefined;
  2194. if (indexName) {
  2195. setIndexName(indexName);
  2196. setConfigIndexName(indexName);
  2197. }
  2198. // Determine output format
  2199. let format: OutputFormat = "cli";
  2200. if (values.csv) format = "csv";
  2201. else if (values.md) format = "md";
  2202. else if (values.xml) format = "xml";
  2203. else if (values.files) format = "files";
  2204. else if (values.json) format = "json";
  2205. // Default limit: 20 for --files/--json, 5 otherwise
  2206. // --all means return all results (use very large limit)
  2207. const defaultLimit = (format === "files" || format === "json") ? 20 : 5;
  2208. const isAll = !!values.all;
  2209. const opts: OutputOptions = {
  2210. format,
  2211. full: !!values.full,
  2212. limit: isAll ? 100000 : (values.n ? parseInt(String(values.n), 10) || defaultLimit : defaultLimit),
  2213. minScore: values["min-score"] ? parseFloat(String(values["min-score"])) || 0 : 0,
  2214. all: isAll,
  2215. collection: values.collection as string[] | undefined,
  2216. lineNumbers: !!values["line-numbers"],
  2217. candidateLimit: values["candidate-limit"] ? parseInt(String(values["candidate-limit"]), 10) : undefined,
  2218. skipRerank: !!values["no-rerank"],
  2219. explain: !!values.explain,
  2220. intent: values.intent as string | undefined,
  2221. chunkStrategy: parseChunkStrategy(values["chunk-strategy"]),
  2222. };
  2223. return {
  2224. command: positionals[0] || "",
  2225. args: positionals.slice(1),
  2226. query: positionals.slice(1).join(" "),
  2227. opts,
  2228. values,
  2229. };
  2230. }
  2231. function getSkillInstallDir(globalInstall: boolean): string {
  2232. return globalInstall
  2233. ? resolve(homedir(), ".agents", "skills", "qmd")
  2234. : resolve(getPwd(), ".agents", "skills", "qmd");
  2235. }
  2236. function getClaudeSkillLinkPath(globalInstall: boolean): string {
  2237. return globalInstall
  2238. ? resolve(homedir(), ".claude", "skills", "qmd")
  2239. : resolve(getPwd(), ".claude", "skills", "qmd");
  2240. }
  2241. function pathExists(path: string): boolean {
  2242. try {
  2243. lstatSync(path);
  2244. return true;
  2245. } catch {
  2246. return false;
  2247. }
  2248. }
  2249. function removePath(path: string): void {
  2250. const stat = lstatSync(path);
  2251. if (stat.isDirectory() && !stat.isSymbolicLink()) {
  2252. rmSync(path, { recursive: true, force: true });
  2253. } else {
  2254. unlinkSync(path);
  2255. }
  2256. }
  2257. function showSkill(): void {
  2258. console.log("QMD Skill (embedded)");
  2259. console.log("");
  2260. const content = getEmbeddedQmdSkillContent();
  2261. process.stdout.write(content.endsWith("\n") ? content : content + "\n");
  2262. }
  2263. function writeEmbeddedSkill(targetDir: string, force: boolean): void {
  2264. if (pathExists(targetDir)) {
  2265. if (!force) {
  2266. throw new Error(`Skill already exists: ${targetDir} (use --force to replace it)`);
  2267. }
  2268. removePath(targetDir);
  2269. }
  2270. mkdirSync(targetDir, { recursive: true });
  2271. for (const file of getEmbeddedQmdSkillFiles()) {
  2272. const destination = resolve(targetDir, file.relativePath);
  2273. mkdirSync(dirname(destination), { recursive: true });
  2274. writeFileSync(destination, file.content, "utf-8");
  2275. }
  2276. }
  2277. function ensureClaudeSymlink(linkPath: string, targetDir: string, force: boolean): boolean {
  2278. const parentDir = dirname(linkPath);
  2279. if (pathExists(parentDir)) {
  2280. const resolvedTargetDir = realpathSync(dirname(targetDir));
  2281. const resolvedLinkParent = realpathSync(parentDir);
  2282. // If .claude/skills already resolves to the same directory as .agents/skills,
  2283. // the skill is already visible to Claude and creating qmd -> qmd would loop.
  2284. if (resolvedTargetDir === resolvedLinkParent) {
  2285. return false;
  2286. }
  2287. }
  2288. const linkTarget = relativePath(parentDir, targetDir) || ".";
  2289. mkdirSync(parentDir, { recursive: true });
  2290. if (pathExists(linkPath)) {
  2291. const stat = lstatSync(linkPath);
  2292. if (stat.isSymbolicLink() && readlinkSync(linkPath) === linkTarget) {
  2293. return true;
  2294. }
  2295. if (!force) {
  2296. throw new Error(`Claude skill path already exists: ${linkPath} (use --force to replace it)`);
  2297. }
  2298. removePath(linkPath);
  2299. }
  2300. symlinkSync(linkTarget, linkPath, "dir");
  2301. return true;
  2302. }
  2303. async function shouldCreateClaudeSymlink(linkPath: string, autoYes: boolean): Promise<boolean> {
  2304. if (autoYes) {
  2305. return true;
  2306. }
  2307. if (!process.stdin.isTTY || !process.stdout.isTTY) {
  2308. console.log(`Tip: create a Claude symlink manually at ${linkPath}`);
  2309. return false;
  2310. }
  2311. const rl = createInterface({
  2312. input: process.stdin,
  2313. output: process.stdout,
  2314. });
  2315. try {
  2316. const answer = await rl.question(`Create a symlink in ${linkPath}? [y/N] `);
  2317. const normalized = answer.trim().toLowerCase();
  2318. return normalized === "y" || normalized === "yes";
  2319. } finally {
  2320. rl.close();
  2321. }
  2322. }
  2323. async function installSkill(globalInstall: boolean, force: boolean, autoYes: boolean): Promise<void> {
  2324. const installDir = getSkillInstallDir(globalInstall);
  2325. writeEmbeddedSkill(installDir, force);
  2326. console.log(`✓ Installed QMD skill to ${installDir}`);
  2327. const claudeLinkPath = getClaudeSkillLinkPath(globalInstall);
  2328. if (!(await shouldCreateClaudeSymlink(claudeLinkPath, autoYes))) {
  2329. return;
  2330. }
  2331. const linked = ensureClaudeSymlink(claudeLinkPath, installDir, force);
  2332. if (linked) {
  2333. console.log(`✓ Linked Claude skill at ${claudeLinkPath}`);
  2334. } else {
  2335. console.log(`✓ Claude already sees the skill via ${dirname(claudeLinkPath)}`);
  2336. }
  2337. }
  2338. function showHelp(): void {
  2339. console.log("qmd — Quick Markdown Search");
  2340. console.log("");
  2341. console.log("Usage:");
  2342. console.log(" qmd <command> [options]");
  2343. console.log("");
  2344. console.log("Primary commands:");
  2345. console.log(" qmd query <query> - Hybrid search with auto expansion + reranking (recommended)");
  2346. console.log(" qmd query 'lex:..\\nvec:...' - Structured query document (you provide lex/vec/hyde lines)");
  2347. console.log(" qmd search <query> - Full-text BM25 keywords (no LLM)");
  2348. console.log(" qmd vsearch <query> - Vector similarity only");
  2349. console.log(" qmd get <file>[:line] [-l N] - Show a single document, optional line slice");
  2350. console.log(" qmd multi-get <pattern> - Batch fetch via glob or comma-separated list");
  2351. console.log(" qmd skill show/install - Show or install the packaged QMD skill");
  2352. console.log(" qmd mcp - Start the MCP server (stdio transport for AI agents)");
  2353. console.log(" qmd bench <fixture.json> - Run search quality benchmarks against a fixture file");
  2354. console.log("");
  2355. console.log("Collections & context:");
  2356. console.log(" qmd collection add/list/remove/rename/show - Manage indexed folders");
  2357. console.log(" qmd context add/list/rm - Attach human-written summaries");
  2358. console.log(" qmd ls [collection[/path]] - Inspect indexed files");
  2359. console.log("");
  2360. console.log("Maintenance:");
  2361. console.log(" qmd status - View index + collection health");
  2362. console.log(" qmd update [--pull] - Re-index collections (optionally git pull first)");
  2363. console.log(" qmd embed [-f] - Generate/refresh vector embeddings");
  2364. console.log(" --max-docs-per-batch <n> - Cap docs loaded into memory per embedding batch");
  2365. console.log(" --max-batch-mb <n> - Cap UTF-8 MB loaded into memory per embedding batch");
  2366. console.log(" qmd cleanup - Clear caches, vacuum DB");
  2367. console.log("");
  2368. console.log("Query syntax (qmd query):");
  2369. console.log(" QMD queries are either a single expand query (no prefix) or a multi-line");
  2370. console.log(" document where every line is typed with lex:, vec:, or hyde:. This grammar");
  2371. console.log(" matches the docs in docs/SYNTAX.md and is enforced in the CLI.");
  2372. console.log("");
  2373. const grammar = [
  2374. `query = expand_query | query_document ;`,
  2375. `expand_query = text | explicit_expand ;`,
  2376. `explicit_expand= "expand:" text ;`,
  2377. `query_document = [ intent_line ] { typed_line } ;`,
  2378. `intent_line = "intent:" text newline ;`,
  2379. `typed_line = type ":" text newline ;`,
  2380. `type = "lex" | "vec" | "hyde" ;`,
  2381. `text = quoted_phrase | plain_text ;`,
  2382. `quoted_phrase = '"' { character } '"' ;`,
  2383. `plain_text = { character } ;`,
  2384. `newline = "\\n" ;`,
  2385. ];
  2386. console.log(" Grammar:");
  2387. for (const line of grammar) {
  2388. console.log(` ${line}`);
  2389. }
  2390. console.log("");
  2391. console.log(" Examples:");
  2392. console.log(" qmd query \"how does auth work\" # single-line → implicit expand");
  2393. console.log(" qmd query $'lex: CAP theorem\\nvec: consistency' # typed query document");
  2394. console.log(" qmd query $'lex: \"exact matches\" sports -baseball' # phrase + negation lex search");
  2395. console.log(" qmd query $'hyde: Hypothetical answer text' # hyde-only document");
  2396. console.log("");
  2397. console.log(" Constraints:");
  2398. console.log(" - Standalone expand queries cannot mix with typed lines.");
  2399. console.log(" - Query documents allow only lex:, vec:, or hyde: prefixes.");
  2400. console.log(" - Each typed line must be single-line text with balanced quotes.");
  2401. console.log("");
  2402. console.log("AI agents & integrations:");
  2403. console.log(" - Run `qmd mcp` to expose the MCP server (stdio) to agents/IDEs.");
  2404. console.log(" - `qmd skill install` installs the QMD skill into ./.agents/skills/qmd.");
  2405. console.log(" - Use `qmd skill install --global` for ~/.agents/skills/qmd.");
  2406. console.log(" - `qmd --skill` is kept as an alias for `qmd skill show`.");
  2407. console.log(" - Advanced: `qmd mcp --http ...` and `qmd mcp --http --daemon` are optional for custom transports.");
  2408. console.log("");
  2409. console.log("Global options:");
  2410. console.log(" --index <name> - Use a named index (default: index)");
  2411. console.log(" QMD_EDITOR_URI - Editor link template for clickable TTY search output");
  2412. console.log("");
  2413. console.log("Search options:");
  2414. console.log(" -n <num> - Max results (default 5, or 20 for --files/--json)");
  2415. console.log(" --all - Return all matches (pair with --min-score)");
  2416. console.log(" --min-score <num> - Minimum similarity score");
  2417. console.log(" --full - Output full document instead of snippet");
  2418. console.log(" -C, --candidate-limit <n> - Max candidates to rerank (default 40, lower = faster)");
  2419. console.log(" --no-rerank - Skip LLM reranking (use RRF scores only, much faster on CPU)");
  2420. console.log(" --line-numbers - Include line numbers in output");
  2421. console.log(" --explain - Include retrieval score traces (query --json/CLI)");
  2422. console.log(" --files | --json | --csv | --md | --xml - Output format");
  2423. console.log(" -c, --collection <name> - Filter by one or more collections");
  2424. console.log("");
  2425. console.log("Embed/query options:");
  2426. console.log(" --chunk-strategy <auto|regex> - Chunking mode (default: regex; auto uses AST for code files)");
  2427. console.log("");
  2428. console.log("Multi-get options:");
  2429. console.log(" -l <num> - Maximum lines per file");
  2430. console.log(" --max-bytes <num> - Skip files larger than N bytes (default 10240)");
  2431. console.log(" --json/--csv/--md/--xml/--files - Same formats as search");
  2432. console.log("");
  2433. console.log(`Index: ${getDbPath()}`);
  2434. }
  2435. async function showVersion(): Promise<void> {
  2436. const scriptDir = dirname(fileURLToPath(import.meta.url));
  2437. const pkgPath = resolve(scriptDir, "..", "..", "package.json");
  2438. const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
  2439. let commit = "";
  2440. try {
  2441. commit = execSync(`git -C ${scriptDir} rev-parse --short HEAD`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
  2442. } catch {
  2443. // Not a git repo or git not available
  2444. }
  2445. const versionStr = commit ? `${pkg.version} (${commit})` : pkg.version;
  2446. console.log(`qmd ${versionStr}`);
  2447. }
  2448. // Main CLI - only run if this is the main module
  2449. const __filename = fileURLToPath(import.meta.url);
  2450. const argv1 = process.argv[1];
  2451. const isMain = argv1 === __filename
  2452. || argv1?.endsWith("/qmd.ts")
  2453. || argv1?.endsWith("/qmd.js")
  2454. || (argv1 != null && realpathSync(argv1) === __filename);
  2455. if (isMain) {
  2456. const cli = parseCLI();
  2457. if (cli.values.version) {
  2458. await showVersion();
  2459. process.exit(0);
  2460. }
  2461. if (cli.values.skill) {
  2462. showSkill();
  2463. process.exit(0);
  2464. }
  2465. if (cli.values.help && cli.command === "skill") {
  2466. console.log("Usage: qmd skill <show|install> [options]");
  2467. console.log("");
  2468. console.log("Commands:");
  2469. console.log(" show Print the packaged QMD skill");
  2470. console.log(" install Install into ./.agents/skills/qmd");
  2471. console.log("");
  2472. console.log("Options:");
  2473. console.log(" --global Install into ~/.agents/skills/qmd");
  2474. console.log(" --yes Also create the .claude/skills/qmd symlink");
  2475. console.log(" -f, --force Replace existing install or symlink");
  2476. process.exit(0);
  2477. }
  2478. if (!cli.command || cli.values.help) {
  2479. showHelp();
  2480. process.exit(cli.values.help ? 0 : 1);
  2481. }
  2482. switch (cli.command) {
  2483. case "context": {
  2484. const subcommand = cli.args[0];
  2485. if (!subcommand) {
  2486. console.error("Usage: qmd context <add|list|rm>");
  2487. console.error("");
  2488. console.error("Commands:");
  2489. console.error(" qmd context add [path] \"text\" - Add context (defaults to current dir)");
  2490. console.error(" qmd context add / \"text\" - Add global context to all collections");
  2491. console.error(" qmd context list - List all contexts");
  2492. console.error(" qmd context rm <path> - Remove context");
  2493. process.exit(1);
  2494. }
  2495. switch (subcommand) {
  2496. case "add": {
  2497. if (cli.args.length < 2) {
  2498. console.error("Usage: qmd context add [path] \"text\"");
  2499. console.error("");
  2500. console.error("Examples:");
  2501. console.error(" qmd context add \"Context for current directory\"");
  2502. console.error(" qmd context add . \"Context for current directory\"");
  2503. console.error(" qmd context add /subfolder \"Context for subfolder\"");
  2504. console.error(" qmd context add / \"Global context for all collections\"");
  2505. console.error("");
  2506. console.error(" Using virtual paths:");
  2507. console.error(" qmd context add qmd://journals/ \"Context for entire journals collection\"");
  2508. console.error(" qmd context add qmd://journals/2024 \"Context for 2024 journals\"");
  2509. process.exit(1);
  2510. }
  2511. let pathArg: string | undefined;
  2512. let contextText: string;
  2513. // Check if first arg looks like a path or if it's the context text
  2514. const firstArg = cli.args[1] || '';
  2515. const secondArg = cli.args[2];
  2516. if (secondArg) {
  2517. // Two args: path + context
  2518. pathArg = firstArg;
  2519. contextText = cli.args.slice(2).join(" ");
  2520. } else {
  2521. // One arg: context only (use current directory)
  2522. pathArg = undefined;
  2523. contextText = firstArg;
  2524. }
  2525. await contextAdd(pathArg, contextText);
  2526. break;
  2527. }
  2528. case "list": {
  2529. contextList();
  2530. break;
  2531. }
  2532. case "rm":
  2533. case "remove": {
  2534. if (cli.args.length < 2 || !cli.args[1]) {
  2535. console.error("Usage: qmd context rm <path>");
  2536. console.error("Examples:");
  2537. console.error(" qmd context rm /");
  2538. console.error(" qmd context rm qmd://journals/2024");
  2539. process.exit(1);
  2540. }
  2541. contextRemove(cli.args[1]);
  2542. break;
  2543. }
  2544. default:
  2545. console.error(`Unknown subcommand: ${subcommand}`);
  2546. console.error("Available: add, list, rm");
  2547. process.exit(1);
  2548. }
  2549. break;
  2550. }
  2551. case "get": {
  2552. if (!cli.args[0]) {
  2553. console.error("Usage: qmd get <filepath>[:line] [--from <line>] [-l <lines>] [--line-numbers]");
  2554. process.exit(1);
  2555. }
  2556. const fromLine = cli.values.from ? parseInt(cli.values.from as string, 10) : undefined;
  2557. const maxLines = cli.values.l ? parseInt(cli.values.l as string, 10) : undefined;
  2558. getDocument(cli.args[0], fromLine, maxLines, cli.opts.lineNumbers);
  2559. break;
  2560. }
  2561. case "multi-get": {
  2562. if (!cli.args[0]) {
  2563. console.error("Usage: qmd multi-get <pattern> [-l <lines>] [--max-bytes <bytes>] [--json|--csv|--md|--xml|--files]");
  2564. console.error(" pattern: glob (e.g., 'journals/2025-05*.md') or comma-separated list");
  2565. process.exit(1);
  2566. }
  2567. const maxLinesMulti = cli.values.l ? parseInt(cli.values.l as string, 10) : undefined;
  2568. const maxBytes = cli.values["max-bytes"] ? parseInt(cli.values["max-bytes"] as string, 10) : DEFAULT_MULTI_GET_MAX_BYTES;
  2569. multiGet(cli.args[0], maxLinesMulti, maxBytes, cli.opts.format);
  2570. break;
  2571. }
  2572. case "ls": {
  2573. listFiles(cli.args[0]);
  2574. break;
  2575. }
  2576. case "collection": {
  2577. const subcommand = cli.args[0];
  2578. switch (subcommand) {
  2579. case "list": {
  2580. collectionList();
  2581. break;
  2582. }
  2583. case "add": {
  2584. const pwd = cli.args[1] || getPwd();
  2585. const resolvedPwd = pwd === '.' ? getPwd() : getRealPath(resolve(pwd));
  2586. const globPattern = cli.values.mask as string || DEFAULT_GLOB;
  2587. const name = cli.values.name as string | undefined;
  2588. await collectionAdd(resolvedPwd, globPattern, name);
  2589. break;
  2590. }
  2591. case "remove":
  2592. case "rm": {
  2593. if (!cli.args[1]) {
  2594. console.error("Usage: qmd collection remove <name>");
  2595. console.error(" Use 'qmd collection list' to see available collections");
  2596. process.exit(1);
  2597. }
  2598. collectionRemove(cli.args[1]);
  2599. break;
  2600. }
  2601. case "rename":
  2602. case "mv": {
  2603. if (!cli.args[1] || !cli.args[2]) {
  2604. console.error("Usage: qmd collection rename <old-name> <new-name>");
  2605. console.error(" Use 'qmd collection list' to see available collections");
  2606. process.exit(1);
  2607. }
  2608. collectionRename(cli.args[1], cli.args[2]);
  2609. break;
  2610. }
  2611. case "set-update":
  2612. case "update-cmd": {
  2613. const name = cli.args[1];
  2614. const cmd = cli.args.slice(2).join(' ') || null;
  2615. if (!name) {
  2616. console.error("Usage: qmd collection update-cmd <name> [command]");
  2617. console.error(" Set the command to run before indexing (e.g., 'git pull')");
  2618. console.error(" Omit command to clear it");
  2619. process.exit(1);
  2620. }
  2621. const { updateCollectionSettings, getCollection } = await import("../collections.js");
  2622. const col = getCollection(name);
  2623. if (!col) {
  2624. console.error(`Collection not found: ${name}`);
  2625. process.exit(1);
  2626. }
  2627. updateCollectionSettings(name, { update: cmd });
  2628. if (cmd) {
  2629. console.log(`✓ Set update command for '${name}': ${cmd}`);
  2630. } else {
  2631. console.log(`✓ Cleared update command for '${name}'`);
  2632. }
  2633. break;
  2634. }
  2635. case "include":
  2636. case "exclude": {
  2637. const name = cli.args[1];
  2638. if (!name) {
  2639. console.error(`Usage: qmd collection ${subcommand} <name>`);
  2640. console.error(` ${subcommand === 'include' ? 'Include' : 'Exclude'} collection in default queries`);
  2641. process.exit(1);
  2642. }
  2643. const { updateCollectionSettings, getCollection } = await import("../collections.js");
  2644. const col = getCollection(name);
  2645. if (!col) {
  2646. console.error(`Collection not found: ${name}`);
  2647. process.exit(1);
  2648. }
  2649. const include = subcommand === 'include';
  2650. updateCollectionSettings(name, { includeByDefault: include });
  2651. console.log(`✓ Collection '${name}' ${include ? 'included in' : 'excluded from'} default queries`);
  2652. break;
  2653. }
  2654. case "show":
  2655. case "info": {
  2656. const name = cli.args[1];
  2657. if (!name) {
  2658. console.error("Usage: qmd collection show <name>");
  2659. process.exit(1);
  2660. }
  2661. const { getCollection } = await import("../collections.js");
  2662. const col = getCollection(name);
  2663. if (!col) {
  2664. console.error(`Collection not found: ${name}`);
  2665. process.exit(1);
  2666. }
  2667. console.log(`Collection: ${name}`);
  2668. console.log(` Path: ${col.path}`);
  2669. console.log(` Pattern: ${col.pattern}`);
  2670. console.log(` Include: ${col.includeByDefault !== false ? 'yes (default)' : 'no'}`);
  2671. if (col.update) {
  2672. console.log(` Update: ${col.update}`);
  2673. }
  2674. if (col.context) {
  2675. const ctxCount = Object.keys(col.context).length;
  2676. console.log(` Contexts: ${ctxCount}`);
  2677. }
  2678. break;
  2679. }
  2680. case "help":
  2681. case undefined: {
  2682. console.log("Usage: qmd collection <command> [options]");
  2683. console.log("");
  2684. console.log("Commands:");
  2685. console.log(" list List all collections");
  2686. console.log(" add <path> [--name NAME] Add a collection");
  2687. console.log(" remove <name> Remove a collection");
  2688. console.log(" rename <old> <new> Rename a collection");
  2689. console.log(" show <name> Show collection details");
  2690. console.log(" update-cmd <name> [cmd] Set pre-update command (e.g., 'git pull')");
  2691. console.log(" include <name> Include in default queries");
  2692. console.log(" exclude <name> Exclude from default queries");
  2693. console.log("");
  2694. console.log("Examples:");
  2695. console.log(" qmd collection add ~/notes --name notes");
  2696. console.log(" qmd collection update-cmd brain 'git pull'");
  2697. console.log(" qmd collection exclude archive");
  2698. process.exit(0);
  2699. }
  2700. default:
  2701. console.error(`Unknown subcommand: ${subcommand}`);
  2702. console.error("Run 'qmd collection help' for usage");
  2703. process.exit(1);
  2704. }
  2705. break;
  2706. }
  2707. case "status":
  2708. await showStatus();
  2709. break;
  2710. case "update":
  2711. await updateCollections();
  2712. break;
  2713. case "embed":
  2714. try {
  2715. const maxDocsPerBatch = parseEmbedBatchOption("maxDocsPerBatch", cli.values["max-docs-per-batch"]);
  2716. const maxBatchMb = parseEmbedBatchOption("maxBatchBytes", cli.values["max-batch-mb"]);
  2717. const embedChunkStrategy = parseChunkStrategy(cli.values["chunk-strategy"]);
  2718. await vectorIndex(DEFAULT_EMBED_MODEL_URI, !!cli.values.force, {
  2719. maxDocsPerBatch,
  2720. maxBatchBytes: maxBatchMb === undefined ? undefined : maxBatchMb * 1024 * 1024,
  2721. chunkStrategy: embedChunkStrategy,
  2722. });
  2723. } catch (error) {
  2724. console.error(error instanceof Error ? error.message : String(error));
  2725. process.exit(1);
  2726. }
  2727. break;
  2728. case "pull": {
  2729. const refresh = cli.values.refresh === undefined ? false : Boolean(cli.values.refresh);
  2730. const models = [
  2731. DEFAULT_EMBED_MODEL_URI,
  2732. DEFAULT_GENERATE_MODEL_URI,
  2733. DEFAULT_RERANK_MODEL_URI,
  2734. ];
  2735. console.log(`${c.bold}Pulling models${c.reset}`);
  2736. const results = await pullModels(models, {
  2737. refresh,
  2738. cacheDir: DEFAULT_MODEL_CACHE_DIR,
  2739. });
  2740. for (const result of results) {
  2741. const size = formatBytes(result.sizeBytes);
  2742. const note = result.refreshed ? "refreshed" : "cached/checked";
  2743. console.log(`- ${result.model} -> ${result.path} (${size}, ${note})`);
  2744. }
  2745. break;
  2746. }
  2747. case "search":
  2748. if (!cli.query) {
  2749. console.error("Usage: qmd search [options] <query>");
  2750. process.exit(1);
  2751. }
  2752. search(cli.query, cli.opts);
  2753. break;
  2754. case "vsearch":
  2755. case "vector-search": // undocumented alias
  2756. if (!cli.query) {
  2757. console.error("Usage: qmd vsearch [options] <query>");
  2758. process.exit(1);
  2759. }
  2760. // Default min-score for vector search is 0.3
  2761. if (!cli.values["min-score"]) {
  2762. cli.opts.minScore = 0.3;
  2763. }
  2764. await vectorSearch(cli.query, cli.opts);
  2765. break;
  2766. case "query":
  2767. case "deep-search": // undocumented alias
  2768. if (!cli.query) {
  2769. console.error("Usage: qmd query [options] <query>");
  2770. process.exit(1);
  2771. }
  2772. await querySearch(cli.query, cli.opts);
  2773. break;
  2774. case "bench": {
  2775. const fixturePath = cli.args[0];
  2776. if (!fixturePath) {
  2777. console.error("Usage: qmd bench <fixture.json> [--json] [-c collection]");
  2778. console.error("");
  2779. console.error("Run search quality benchmarks against a fixture file.");
  2780. console.error("See src/bench/fixtures/example.json for the fixture format.");
  2781. process.exit(1);
  2782. }
  2783. const { runBenchmark } = await import("../bench/bench.js");
  2784. const benchCollection = cli.opts.collection;
  2785. await runBenchmark(fixturePath, {
  2786. json: !!(cli.opts as { json?: boolean }).json,
  2787. collection: Array.isArray(benchCollection) ? benchCollection[0] : benchCollection,
  2788. });
  2789. break;
  2790. }
  2791. case "mcp": {
  2792. const sub = cli.args[0]; // stop | status | undefined
  2793. // Cache dir for PID/log files — same dir as the index
  2794. const cacheDir = process.env.XDG_CACHE_HOME
  2795. ? resolve(process.env.XDG_CACHE_HOME, "qmd")
  2796. : resolve(homedir(), ".cache", "qmd");
  2797. const pidPath = resolve(cacheDir, "mcp.pid");
  2798. // Subcommands take priority over flags
  2799. if (sub === "stop") {
  2800. if (!existsSync(pidPath)) {
  2801. console.log("Not running (no PID file).");
  2802. process.exit(0);
  2803. }
  2804. const pid = parseInt(readFileSync(pidPath, "utf-8").trim());
  2805. try {
  2806. process.kill(pid, 0); // alive?
  2807. process.kill(pid, "SIGTERM");
  2808. unlinkSync(pidPath);
  2809. console.log(`Stopped QMD MCP server (PID ${pid}).`);
  2810. } catch {
  2811. unlinkSync(pidPath);
  2812. console.log("Cleaned up stale PID file (server was not running).");
  2813. }
  2814. process.exit(0);
  2815. }
  2816. if (cli.values.http) {
  2817. const port = Number(cli.values.port) || 8181;
  2818. if (cli.values.daemon) {
  2819. // Guard: check if already running
  2820. if (existsSync(pidPath)) {
  2821. const existingPid = parseInt(readFileSync(pidPath, "utf-8").trim());
  2822. try {
  2823. process.kill(existingPid, 0); // alive?
  2824. console.error(`Already running (PID ${existingPid}). Run 'qmd mcp stop' first.`);
  2825. process.exit(1);
  2826. } catch {
  2827. // Stale PID file — continue
  2828. }
  2829. }
  2830. mkdirSync(cacheDir, { recursive: true });
  2831. const logPath = resolve(cacheDir, "mcp.log");
  2832. const logFd = openSync(logPath, "w"); // truncate — fresh log per daemon run
  2833. const selfPath = fileURLToPath(import.meta.url);
  2834. const spawnArgs = selfPath.endsWith(".ts")
  2835. ? ["--import", pathJoin(dirname(selfPath), "..", "..", "node_modules", "tsx", "dist", "esm", "index.mjs"), selfPath, "mcp", "--http", "--port", String(port)]
  2836. : [selfPath, "mcp", "--http", "--port", String(port)];
  2837. const child = nodeSpawn(process.execPath, spawnArgs, {
  2838. stdio: ["ignore", logFd, logFd],
  2839. detached: true,
  2840. });
  2841. child.unref();
  2842. closeSync(logFd); // parent's copy; child inherited the fd
  2843. writeFileSync(pidPath, String(child.pid));
  2844. console.log(`Started on http://localhost:${port}/mcp (PID ${child.pid})`);
  2845. console.log(`Logs: ${logPath}`);
  2846. process.exit(0);
  2847. }
  2848. // Foreground HTTP mode — remove top-level cursor handlers so the
  2849. // async cleanup handlers in startMcpHttpServer actually run.
  2850. process.removeAllListeners("SIGTERM");
  2851. process.removeAllListeners("SIGINT");
  2852. const { startMcpHttpServer } = await import("../mcp/server.js");
  2853. try {
  2854. await startMcpHttpServer(port);
  2855. } catch (e: any) {
  2856. if (e?.code === "EADDRINUSE") {
  2857. console.error(`Port ${port} already in use. Try a different port with --port.`);
  2858. process.exit(1);
  2859. }
  2860. throw e;
  2861. }
  2862. } else {
  2863. // Default: stdio transport
  2864. const { startMcpServer } = await import("../mcp/server.js");
  2865. await startMcpServer();
  2866. }
  2867. break;
  2868. }
  2869. case "skill": {
  2870. const subcommand = cli.args[0];
  2871. switch (subcommand) {
  2872. case "show": {
  2873. showSkill();
  2874. break;
  2875. }
  2876. case "install": {
  2877. try {
  2878. await installSkill(Boolean(cli.values.global), Boolean(cli.values.force), Boolean(cli.values.yes));
  2879. } catch (error) {
  2880. console.error(error instanceof Error ? error.message : String(error));
  2881. process.exit(1);
  2882. }
  2883. break;
  2884. }
  2885. case "help":
  2886. case undefined: {
  2887. console.log("Usage: qmd skill <show|install> [options]");
  2888. console.log("");
  2889. console.log("Commands:");
  2890. console.log(" show Print the packaged QMD skill");
  2891. console.log(" install Install into ./.agents/skills/qmd");
  2892. console.log("");
  2893. console.log("Options:");
  2894. console.log(" --global Install into ~/.agents/skills/qmd");
  2895. console.log(" --yes Also create the .claude/skills/qmd symlink");
  2896. console.log(" -f, --force Replace existing install or symlink");
  2897. process.exit(0);
  2898. }
  2899. default:
  2900. console.error(`Unknown subcommand: ${subcommand}`);
  2901. console.error("Run 'qmd skill help' for usage");
  2902. process.exit(1);
  2903. }
  2904. break;
  2905. }
  2906. case "cleanup": {
  2907. const db = getDb();
  2908. // 1. Clear llm_cache
  2909. const cacheCount = deleteLLMCache(db);
  2910. console.log(`${c.green}✓${c.reset} Cleared ${cacheCount} cached API responses`);
  2911. // 2. Remove orphaned vectors
  2912. const orphanedVecs = cleanupOrphanedVectors(db);
  2913. if (orphanedVecs > 0) {
  2914. console.log(`${c.green}✓${c.reset} Removed ${orphanedVecs} orphaned embedding chunks`);
  2915. } else {
  2916. console.log(`${c.dim}No orphaned embeddings to remove${c.reset}`);
  2917. }
  2918. // 3. Remove inactive documents
  2919. const inactiveDocs = deleteInactiveDocuments(db);
  2920. if (inactiveDocs > 0) {
  2921. console.log(`${c.green}✓${c.reset} Removed ${inactiveDocs} inactive document records`);
  2922. }
  2923. // 4. Vacuum to reclaim space
  2924. vacuumDatabase(db);
  2925. console.log(`${c.green}✓${c.reset} Database vacuumed`);
  2926. closeDb();
  2927. break;
  2928. }
  2929. default:
  2930. console.error(`Unknown command: ${cli.command}`);
  2931. console.error("Run 'qmd --help' for usage.");
  2932. process.exit(1);
  2933. }
  2934. if (cli.command !== "mcp") {
  2935. await disposeDefaultLlamaCpp();
  2936. process.exit(0);
  2937. }
  2938. } // end if (main module)