@tobilu/qmd 1.0.6 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/store.d.ts CHANGED
@@ -548,6 +548,12 @@ export declare function getCollectionsWithoutContext(db: Database): {
548
548
  * Useful for suggesting where context might be needed.
549
549
  */
550
550
  export declare function getTopLevelPathsWithoutContext(db: Database, collectionName: string): string[];
551
+ /**
552
+ * Validate that a vec/hyde query doesn't use lex-only syntax.
553
+ * Returns error message if invalid, null if valid.
554
+ */
555
+ export declare function validateSemanticQuery(query: string): string | null;
556
+ export declare function validateLexQuery(query: string): string | null;
551
557
  export declare function searchFTS(db: Database, query: string, limit?: number, collectionName?: string): SearchResult[];
552
558
  export declare function searchVec(db: Database, query: string, model: string, limit?: number, collectionName?: string, session?: ILLMSession, precomputedEmbedding?: number[]): Promise<SearchResult[]>;
553
559
  /**
@@ -630,12 +636,18 @@ export declare function addLineNumbers(text: string, startLine?: number): string
630
636
  export interface SearchHooks {
631
637
  /** BM25 probe found strong signal — expansion will be skipped */
632
638
  onStrongSignal?: (topScore: number) => void;
633
- /** Query expansion complete. Empty array = strong signal skip (no expansion). */
634
- onExpand?: (original: string, expanded: ExpandedQuery[]) => void;
639
+ /** Query expansion starting */
640
+ onExpandStart?: () => void;
641
+ /** Query expansion complete. Empty array = strong signal skip. elapsedMs = time taken. */
642
+ onExpand?: (original: string, expanded: ExpandedQuery[], elapsedMs: number) => void;
643
+ /** Embedding starting (vec/hyde queries) */
644
+ onEmbedStart?: (count: number) => void;
645
+ /** Embedding complete */
646
+ onEmbedDone?: (elapsedMs: number) => void;
635
647
  /** Reranking is about to start */
636
648
  onRerankStart?: (chunkCount: number) => void;
637
649
  /** Reranking finished */
638
- onRerankDone?: () => void;
650
+ onRerankDone?: (elapsedMs: number) => void;
639
651
  }
640
652
  export interface HybridQueryOptions {
641
653
  collection?: string;
@@ -694,3 +706,43 @@ export interface VectorSearchResult {
694
706
  * 4. Sort by score descending, filter by minScore, slice to limit
695
707
  */
696
708
  export declare function vectorSearchQuery(store: Store, query: string, options?: VectorSearchOptions): Promise<VectorSearchResult[]>;
709
+ /**
710
+ * A single sub-search in a structured search request.
711
+ * Matches the format used in QMD training data.
712
+ */
713
+ export interface StructuredSubSearch {
714
+ /** Search type: 'lex' for BM25, 'vec' for semantic, 'hyde' for hypothetical */
715
+ type: 'lex' | 'vec' | 'hyde';
716
+ /** The search query text */
717
+ query: string;
718
+ /** Optional line number for error reporting (CLI parser) */
719
+ line?: number;
720
+ }
721
+ export interface StructuredSearchOptions {
722
+ collections?: string[];
723
+ limit?: number;
724
+ minScore?: number;
725
+ candidateLimit?: number;
726
+ /** Future: domain intent hint for routing/boosting */
727
+ intent?: string;
728
+ hooks?: SearchHooks;
729
+ }
730
+ /**
731
+ * Structured search: execute pre-expanded queries without LLM query expansion.
732
+ *
733
+ * Designed for LLM callers (MCP/HTTP) that generate their own query expansions.
734
+ * Skips the internal expandQuery() step — goes directly to:
735
+ *
736
+ * Pipeline:
737
+ * 1. Route searches: lex→FTS, vec/hyde→vector (batch embed)
738
+ * 2. RRF fusion across all result lists
739
+ * 3. Chunk documents + keyword-best-chunk selection
740
+ * 4. Rerank on chunks
741
+ * 5. Position-aware score blending
742
+ * 6. Dedup, filter, slice
743
+ *
744
+ * This is the recommended endpoint for capable LLMs — they can generate
745
+ * better query variations than our small local model, especially for
746
+ * domain-specific or nuanced queries.
747
+ */
748
+ export declare function structuredSearch(store: Store, searches: StructuredSubSearch[], options?: StructuredSearchOptions): Promise<HybridQueryResult[]>;
package/dist/store.js CHANGED
@@ -1510,15 +1510,108 @@ export function getTopLevelPathsWithoutContext(db, collectionName) {
1510
1510
  function sanitizeFTS5Term(term) {
1511
1511
  return term.replace(/[^\p{L}\p{N}']/gu, '').toLowerCase();
1512
1512
  }
1513
+ /**
1514
+ * Parse lex query syntax into FTS5 query.
1515
+ *
1516
+ * Supports:
1517
+ * - Quoted phrases: "exact phrase" → "exact phrase" (exact match)
1518
+ * - Negation: -term or -"phrase" → uses FTS5 NOT operator
1519
+ * - Plain terms: term → "term"* (prefix match)
1520
+ *
1521
+ * FTS5 NOT is a binary operator: `term1 NOT term2` means "match term1 but not term2".
1522
+ * So `-term` only works when there are also positive terms.
1523
+ *
1524
+ * Examples:
1525
+ * performance -sports → "performance"* NOT "sports"*
1526
+ * "machine learning" → "machine learning"
1527
+ */
1513
1528
  function buildFTS5Query(query) {
1514
- const terms = query.split(/\s+/)
1515
- .map(t => sanitizeFTS5Term(t))
1516
- .filter(t => t.length > 0);
1517
- if (terms.length === 0)
1529
+ const positive = [];
1530
+ const negative = [];
1531
+ let i = 0;
1532
+ const s = query.trim();
1533
+ while (i < s.length) {
1534
+ // Skip whitespace
1535
+ while (i < s.length && /\s/.test(s[i]))
1536
+ i++;
1537
+ if (i >= s.length)
1538
+ break;
1539
+ // Check for negation prefix
1540
+ const negated = s[i] === '-';
1541
+ if (negated)
1542
+ i++;
1543
+ // Check for quoted phrase
1544
+ if (s[i] === '"') {
1545
+ const start = i + 1;
1546
+ i++;
1547
+ while (i < s.length && s[i] !== '"')
1548
+ i++;
1549
+ const phrase = s.slice(start, i).trim();
1550
+ i++; // skip closing quote
1551
+ if (phrase.length > 0) {
1552
+ const sanitized = phrase.split(/\s+/).map(t => sanitizeFTS5Term(t)).filter(t => t).join(' ');
1553
+ if (sanitized) {
1554
+ const ftsPhrase = `"${sanitized}"`; // Exact phrase, no prefix match
1555
+ if (negated) {
1556
+ negative.push(ftsPhrase);
1557
+ }
1558
+ else {
1559
+ positive.push(ftsPhrase);
1560
+ }
1561
+ }
1562
+ }
1563
+ }
1564
+ else {
1565
+ // Plain term (until whitespace or quote)
1566
+ const start = i;
1567
+ while (i < s.length && !/[\s"]/.test(s[i]))
1568
+ i++;
1569
+ const term = s.slice(start, i);
1570
+ const sanitized = sanitizeFTS5Term(term);
1571
+ if (sanitized) {
1572
+ const ftsTerm = `"${sanitized}"*`; // Prefix match
1573
+ if (negated) {
1574
+ negative.push(ftsTerm);
1575
+ }
1576
+ else {
1577
+ positive.push(ftsTerm);
1578
+ }
1579
+ }
1580
+ }
1581
+ }
1582
+ if (positive.length === 0 && negative.length === 0)
1583
+ return null;
1584
+ // If only negative terms, we can't search (FTS5 NOT is binary)
1585
+ if (positive.length === 0)
1518
1586
  return null;
1519
- if (terms.length === 1)
1520
- return `"${terms[0]}"*`;
1521
- return terms.map(t => `"${t}"*`).join(' AND ');
1587
+ // Join positive terms with AND
1588
+ let result = positive.join(' AND ');
1589
+ // Add NOT clause for negative terms
1590
+ for (const neg of negative) {
1591
+ result = `${result} NOT ${neg}`;
1592
+ }
1593
+ return result;
1594
+ }
1595
+ /**
1596
+ * Validate that a vec/hyde query doesn't use lex-only syntax.
1597
+ * Returns error message if invalid, null if valid.
1598
+ */
1599
+ export function validateSemanticQuery(query) {
1600
+ // Check for negation syntax
1601
+ if (/-\w/.test(query) || /-"/.test(query)) {
1602
+ return 'Negation (-term) is not supported in vec/hyde queries. Use lex for exclusions.';
1603
+ }
1604
+ return null;
1605
+ }
1606
+ export function validateLexQuery(query) {
1607
+ if (/[\r\n]/.test(query)) {
1608
+ return 'Lex queries must be a single line. Remove newline characters or split into separate lex: lines.';
1609
+ }
1610
+ const quoteCount = (query.match(/"/g) ?? []).length;
1611
+ if (quoteCount % 2 === 1) {
1612
+ return 'Lex query has an unmatched double quote ("). Add the closing quote or remove it.';
1613
+ }
1614
+ return null;
1522
1615
  }
1523
1616
  export function searchFTS(db, query, limit = 20, collectionName) {
1524
1617
  const ftsQuery = buildFTS5Query(query);
@@ -2186,10 +2279,12 @@ export async function hybridQuery(store, query, options) {
2186
2279
  if (hasStrongSignal)
2187
2280
  hooks?.onStrongSignal?.(topScore);
2188
2281
  // Step 2: Expand query (or skip if strong signal)
2282
+ hooks?.onExpandStart?.();
2283
+ const expandStart = Date.now();
2189
2284
  const expanded = hasStrongSignal
2190
2285
  ? []
2191
2286
  : await store.expandQuery(query);
2192
- hooks?.onExpand?.(query, expanded);
2287
+ hooks?.onExpand?.(query, expanded, Date.now() - expandStart);
2193
2288
  // Seed with initial FTS results (avoid re-running original query FTS)
2194
2289
  if (initialFts.length > 0) {
2195
2290
  for (const r of initialFts)
@@ -2231,7 +2326,10 @@ export async function hybridQuery(store, query, options) {
2231
2326
  // Batch embed all vector queries in a single call
2232
2327
  const llm = getDefaultLlamaCpp();
2233
2328
  const textsToEmbed = vecQueries.map(q => formatQueryForEmbedding(q.text));
2329
+ hooks?.onEmbedStart?.(textsToEmbed.length);
2330
+ const embedStart = Date.now();
2234
2331
  const embeddings = await llm.embedBatch(textsToEmbed);
2332
+ hooks?.onEmbedDone?.(Date.now() - embedStart);
2235
2333
  // Run sqlite-vec lookups with pre-computed embeddings
2236
2334
  for (let i = 0; i < vecQueries.length; i++) {
2237
2335
  const embedding = embeddings[i]?.embedding;
@@ -2279,8 +2377,9 @@ export async function hybridQuery(store, query, options) {
2279
2377
  }
2280
2378
  // Step 6: Rerank chunks (NOT full bodies)
2281
2379
  hooks?.onRerankStart?.(chunksToRerank.length);
2380
+ const rerankStart = Date.now();
2282
2381
  const reranked = await store.rerank(query, chunksToRerank);
2283
- hooks?.onRerankDone?.();
2382
+ hooks?.onRerankDone?.(Date.now() - rerankStart);
2284
2383
  // Step 7: Blend RRF position score with reranker score
2285
2384
  // Position-aware weights: top retrieval results get more protection from reranker disagreement
2286
2385
  const candidateMap = new Map(candidates.map(c => [c.file, {
@@ -2344,9 +2443,10 @@ export async function vectorSearchQuery(store, query, options) {
2344
2443
  if (!hasVectors)
2345
2444
  return [];
2346
2445
  // Expand query — filter to vec/hyde only (lex queries target FTS, not vector)
2446
+ const expandStart = Date.now();
2347
2447
  const allExpanded = await store.expandQuery(query);
2348
2448
  const vecExpanded = allExpanded.filter(q => q.type !== 'lex');
2349
- options?.hooks?.onExpand?.(query, vecExpanded);
2449
+ options?.hooks?.onExpand?.(query, vecExpanded, Date.now() - expandStart);
2350
2450
  // Run original + vec/hyde expanded through vector, sequentially — concurrent embed() hangs
2351
2451
  const queryTexts = [query, ...vecExpanded.map(q => q.text)];
2352
2452
  const allResults = new Map();
@@ -2372,3 +2472,182 @@ export async function vectorSearchQuery(store, query, options) {
2372
2472
  .filter(r => r.score >= minScore)
2373
2473
  .slice(0, limit);
2374
2474
  }
2475
+ /**
2476
+ * Structured search: execute pre-expanded queries without LLM query expansion.
2477
+ *
2478
+ * Designed for LLM callers (MCP/HTTP) that generate their own query expansions.
2479
+ * Skips the internal expandQuery() step — goes directly to:
2480
+ *
2481
+ * Pipeline:
2482
+ * 1. Route searches: lex→FTS, vec/hyde→vector (batch embed)
2483
+ * 2. RRF fusion across all result lists
2484
+ * 3. Chunk documents + keyword-best-chunk selection
2485
+ * 4. Rerank on chunks
2486
+ * 5. Position-aware score blending
2487
+ * 6. Dedup, filter, slice
2488
+ *
2489
+ * This is the recommended endpoint for capable LLMs — they can generate
2490
+ * better query variations than our small local model, especially for
2491
+ * domain-specific or nuanced queries.
2492
+ */
2493
+ export async function structuredSearch(store, searches, options) {
2494
+ const limit = options?.limit ?? 10;
2495
+ const minScore = options?.minScore ?? 0;
2496
+ const candidateLimit = options?.candidateLimit ?? RERANK_CANDIDATE_LIMIT;
2497
+ const hooks = options?.hooks;
2498
+ const collections = options?.collections;
2499
+ if (searches.length === 0)
2500
+ return [];
2501
+ // Validate queries before executing
2502
+ for (const search of searches) {
2503
+ const location = search.line ? `Line ${search.line}` : 'Structured search';
2504
+ if (/[\r\n]/.test(search.query)) {
2505
+ throw new Error(`${location} (${search.type}): queries must be single-line. Remove newline characters.`);
2506
+ }
2507
+ if (search.type === 'lex') {
2508
+ const error = validateLexQuery(search.query);
2509
+ if (error) {
2510
+ throw new Error(`${location} (lex): ${error}`);
2511
+ }
2512
+ }
2513
+ else if (search.type === 'vec' || search.type === 'hyde') {
2514
+ const error = validateSemanticQuery(search.query);
2515
+ if (error) {
2516
+ throw new Error(`${location} (${search.type}): ${error}`);
2517
+ }
2518
+ }
2519
+ }
2520
+ const rankedLists = [];
2521
+ const docidMap = new Map(); // filepath -> docid
2522
+ const hasVectors = !!store.db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
2523
+ // Helper to run search across collections (or all if undefined)
2524
+ const collectionList = collections ?? [undefined]; // undefined = all collections
2525
+ // Step 1: Run FTS for all lex searches (sync, instant)
2526
+ for (const search of searches) {
2527
+ if (search.type === 'lex') {
2528
+ for (const coll of collectionList) {
2529
+ const ftsResults = store.searchFTS(search.query, 20, coll);
2530
+ if (ftsResults.length > 0) {
2531
+ for (const r of ftsResults)
2532
+ docidMap.set(r.filepath, r.docid);
2533
+ rankedLists.push(ftsResults.map(r => ({
2534
+ file: r.filepath, displayPath: r.displayPath,
2535
+ title: r.title, body: r.body || "", score: r.score,
2536
+ })));
2537
+ }
2538
+ }
2539
+ }
2540
+ }
2541
+ // Step 2: Batch embed and run vector searches for vec/hyde
2542
+ if (hasVectors) {
2543
+ const vecSearches = searches.filter(s => s.type === 'vec' || s.type === 'hyde');
2544
+ if (vecSearches.length > 0) {
2545
+ const llm = getDefaultLlamaCpp();
2546
+ const textsToEmbed = vecSearches.map(s => formatQueryForEmbedding(s.query));
2547
+ hooks?.onEmbedStart?.(textsToEmbed.length);
2548
+ const embedStart = Date.now();
2549
+ const embeddings = await llm.embedBatch(textsToEmbed);
2550
+ hooks?.onEmbedDone?.(Date.now() - embedStart);
2551
+ for (let i = 0; i < vecSearches.length; i++) {
2552
+ const embedding = embeddings[i]?.embedding;
2553
+ if (!embedding)
2554
+ continue;
2555
+ for (const coll of collectionList) {
2556
+ const vecResults = await store.searchVec(vecSearches[i].query, DEFAULT_EMBED_MODEL, 20, coll, undefined, embedding);
2557
+ if (vecResults.length > 0) {
2558
+ for (const r of vecResults)
2559
+ docidMap.set(r.filepath, r.docid);
2560
+ rankedLists.push(vecResults.map(r => ({
2561
+ file: r.filepath, displayPath: r.displayPath,
2562
+ title: r.title, body: r.body || "", score: r.score,
2563
+ })));
2564
+ }
2565
+ }
2566
+ }
2567
+ }
2568
+ }
2569
+ if (rankedLists.length === 0)
2570
+ return [];
2571
+ // Step 3: RRF fusion — first list gets 2x weight (assume caller ordered by importance)
2572
+ const weights = rankedLists.map((_, i) => i === 0 ? 2.0 : 1.0);
2573
+ const fused = reciprocalRankFusion(rankedLists, weights);
2574
+ const candidates = fused.slice(0, candidateLimit);
2575
+ if (candidates.length === 0)
2576
+ return [];
2577
+ hooks?.onExpand?.("", [], 0); // Signal no expansion (pre-expanded)
2578
+ // Step 4: Chunk documents, pick best chunk per doc for reranking
2579
+ // Use first lex query as the "query" for keyword matching, or first vec if no lex
2580
+ const primaryQuery = searches.find(s => s.type === 'lex')?.query
2581
+ || searches.find(s => s.type === 'vec')?.query
2582
+ || searches[0]?.query || "";
2583
+ const queryTerms = primaryQuery.toLowerCase().split(/\s+/).filter(t => t.length > 2);
2584
+ const chunksToRerank = [];
2585
+ const docChunkMap = new Map();
2586
+ for (const cand of candidates) {
2587
+ const chunks = chunkDocument(cand.body);
2588
+ if (chunks.length === 0)
2589
+ continue;
2590
+ // Pick chunk with most keyword overlap
2591
+ let bestIdx = 0;
2592
+ let bestScore = -1;
2593
+ for (let i = 0; i < chunks.length; i++) {
2594
+ const chunkLower = chunks[i].text.toLowerCase();
2595
+ const score = queryTerms.reduce((acc, term) => acc + (chunkLower.includes(term) ? 1 : 0), 0);
2596
+ if (score > bestScore) {
2597
+ bestScore = score;
2598
+ bestIdx = i;
2599
+ }
2600
+ }
2601
+ chunksToRerank.push({ file: cand.file, text: chunks[bestIdx].text });
2602
+ docChunkMap.set(cand.file, { chunks, bestIdx });
2603
+ }
2604
+ // Step 5: Rerank chunks
2605
+ hooks?.onRerankStart?.(chunksToRerank.length);
2606
+ const rerankStart2 = Date.now();
2607
+ const reranked = await store.rerank(primaryQuery, chunksToRerank);
2608
+ hooks?.onRerankDone?.(Date.now() - rerankStart2);
2609
+ // Step 6: Blend RRF position score with reranker score
2610
+ const candidateMap = new Map(candidates.map(c => [c.file, {
2611
+ displayPath: c.displayPath, title: c.title, body: c.body,
2612
+ }]));
2613
+ const rrfRankMap = new Map(candidates.map((c, i) => [c.file, i + 1]));
2614
+ const blended = reranked.map(r => {
2615
+ const rrfRank = rrfRankMap.get(r.file) || candidateLimit;
2616
+ let rrfWeight;
2617
+ if (rrfRank <= 3)
2618
+ rrfWeight = 0.75;
2619
+ else if (rrfRank <= 10)
2620
+ rrfWeight = 0.60;
2621
+ else
2622
+ rrfWeight = 0.40;
2623
+ const rrfScore = 1 / rrfRank;
2624
+ const blendedScore = rrfWeight * rrfScore + (1 - rrfWeight) * r.score;
2625
+ const candidate = candidateMap.get(r.file);
2626
+ const chunkInfo = docChunkMap.get(r.file);
2627
+ const bestIdx = chunkInfo?.bestIdx ?? 0;
2628
+ const bestChunk = chunkInfo?.chunks[bestIdx]?.text || candidate?.body || "";
2629
+ const bestChunkPos = chunkInfo?.chunks[bestIdx]?.pos || 0;
2630
+ return {
2631
+ file: r.file,
2632
+ displayPath: candidate?.displayPath || "",
2633
+ title: candidate?.title || "",
2634
+ body: candidate?.body || "",
2635
+ bestChunk,
2636
+ bestChunkPos,
2637
+ score: blendedScore,
2638
+ context: store.getContextForFile(r.file),
2639
+ docid: docidMap.get(r.file) || "",
2640
+ };
2641
+ }).sort((a, b) => b.score - a.score);
2642
+ // Step 7: Dedup by file
2643
+ const seenFiles = new Set();
2644
+ return blended
2645
+ .filter(r => {
2646
+ if (seenFiles.has(r.file))
2647
+ return false;
2648
+ seenFiles.add(r.file);
2649
+ return true;
2650
+ })
2651
+ .filter(r => r.score >= minScore)
2652
+ .slice(0, limit);
2653
+ }
package/package.json CHANGED
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "@tobilu/qmd",
3
- "version": "1.0.6",
3
+ "version": "1.1.1",
4
4
  "description": "Query Markup Documents - On-device hybrid search for markdown files with BM25, vector search, and LLM reranking",
5
5
  "type": "module",
6
6
  "bin": {
7
- "qmd": "qmd"
7
+ "qmd": "dist/qmd.js"
8
8
  },
9
9
  "files": [
10
10
  "dist/",
11
- "qmd",
12
11
  "LICENSE",
13
12
  "CHANGELOG.md"
14
13
  ],
15
14
  "scripts": {
16
15
  "prepare": "[ -d .git ] && ./scripts/install-hooks.sh || true",
17
- "build": "tsc -p tsconfig.build.json",
16
+ "build": "tsc -p tsconfig.build.json && printf '#!/usr/bin/env node\n' | cat - dist/qmd.js > dist/qmd.tmp && mv dist/qmd.tmp dist/qmd.js && chmod +x dist/qmd.js",
18
17
  "test": "vitest run --reporter=verbose test/",
19
18
  "qmd": "tsx src/qmd.ts",
20
19
  "index": "tsx src/qmd.ts index",
package/qmd DELETED
@@ -1,46 +0,0 @@
1
- #!/usr/bin/env bash
2
- # qmd - Quick Markdown Search
3
- set -euo pipefail
4
-
5
- # Find node - prefer PATH, fallback to known locations
6
- find_node() {
7
- if command -v node &>/dev/null; then
8
- local ver=$(node --version 2>/dev/null | sed 's/^v//' || echo "0")
9
- local major="${ver%%.*}"
10
- if [[ "$major" -ge 22 ]]; then
11
- command -v node
12
- return 0
13
- fi
14
- fi
15
-
16
- # Fallback: derive paths (need HOME)
17
- : "${HOME:=$(eval echo ~)}"
18
-
19
- # Check known locations
20
- local candidates=(
21
- "$HOME/.local/share/mise/installs/node/latest/bin/node"
22
- "$HOME/.local/share/mise/shims/node"
23
- "$HOME/.asdf/shims/node"
24
- "/opt/homebrew/bin/node"
25
- "/usr/local/bin/node"
26
- "$HOME/.nvm/current/bin/node"
27
- )
28
- for c in "${candidates[@]}"; do
29
- [[ -x "$c" ]] && { echo "$c"; return 0; }
30
- done
31
-
32
- return 1
33
- }
34
-
35
- NODE=$(find_node) || { echo "Error: node (>=22) not found. Install from https://nodejs.org" >&2; exit 1; }
36
-
37
- # Resolve symlinks to find script location
38
- SOURCE="${BASH_SOURCE[0]}"
39
- while [[ -L "$SOURCE" ]]; do
40
- DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
41
- SOURCE="$(readlink "$SOURCE")"
42
- [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
43
- done
44
- SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
45
-
46
- exec "$NODE" "$SCRIPT_DIR/dist/qmd.js" "$@"