context-mode 1.0.52 → 1.0.54

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/build/store.js CHANGED
@@ -212,13 +212,17 @@ export class ContentStore {
212
212
  // Search path (hot)
213
213
  #stmtSearchPorter;
214
214
  #stmtSearchPorterFiltered;
215
+ #stmtSearchPorterExact;
215
216
  #stmtSearchTrigram;
216
217
  #stmtSearchTrigramFiltered;
218
+ #stmtSearchTrigramExact;
217
219
  #stmtFuzzyVocab;
218
220
  #stmtSearchPorterContentType;
219
221
  #stmtSearchPorterFilteredContentType;
222
+ #stmtSearchPorterExactContentType;
220
223
  #stmtSearchTrigramContentType;
221
224
  #stmtSearchTrigramFilteredContentType;
225
+ #stmtSearchTrigramExactContentType;
222
226
  // Read path
223
227
  #stmtListSources;
224
228
  #stmtChunksBySource;
@@ -278,6 +282,8 @@ export class ContentStore {
278
282
  CREATE TABLE IF NOT EXISTS vocabulary (
279
283
  word TEXT PRIMARY KEY
280
284
  );
285
+
286
+ CREATE INDEX IF NOT EXISTS idx_sources_label ON sources(label);
281
287
  `);
282
288
  }
283
289
  #prepareStatements() {
@@ -320,6 +326,20 @@ export class ContentStore {
320
326
  WHERE chunks MATCH ? AND sources.label LIKE ?
321
327
  ORDER BY rank
322
328
  LIMIT ?
329
+ `);
330
+ this.#stmtSearchPorterExact = this.#db.prepare(`
331
+ SELECT
332
+ chunks.title,
333
+ chunks.content,
334
+ chunks.content_type,
335
+ sources.label,
336
+ bm25(chunks, 5.0, 1.0) AS rank,
337
+ highlight(chunks, 1, char(2), char(3)) AS highlighted
338
+ FROM chunks
339
+ JOIN sources ON sources.id = chunks.source_id
340
+ WHERE chunks MATCH ? AND sources.label = ?
341
+ ORDER BY rank
342
+ LIMIT ?
323
343
  `);
324
344
  this.#stmtSearchTrigram = this.#db.prepare(`
325
345
  SELECT
@@ -348,6 +368,20 @@ export class ContentStore {
348
368
  WHERE chunks_trigram MATCH ? AND sources.label LIKE ?
349
369
  ORDER BY rank
350
370
  LIMIT ?
371
+ `);
372
+ this.#stmtSearchTrigramExact = this.#db.prepare(`
373
+ SELECT
374
+ chunks_trigram.title,
375
+ chunks_trigram.content,
376
+ chunks_trigram.content_type,
377
+ sources.label,
378
+ bm25(chunks_trigram, 5.0, 1.0) AS rank,
379
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
380
+ FROM chunks_trigram
381
+ JOIN sources ON sources.id = chunks_trigram.source_id
382
+ WHERE chunks_trigram MATCH ? AND sources.label = ?
383
+ ORDER BY rank
384
+ LIMIT ?
351
385
  `);
352
386
  // Content-type filtered variants
353
387
  this.#stmtSearchPorterContentType = this.#db.prepare(`
@@ -377,6 +411,20 @@ export class ContentStore {
377
411
  WHERE chunks MATCH ? AND sources.label LIKE ? AND chunks.content_type = ?
378
412
  ORDER BY rank
379
413
  LIMIT ?
414
+ `);
415
+ this.#stmtSearchPorterExactContentType = this.#db.prepare(`
416
+ SELECT
417
+ chunks.title,
418
+ chunks.content,
419
+ chunks.content_type,
420
+ sources.label,
421
+ bm25(chunks, 5.0, 1.0) AS rank,
422
+ highlight(chunks, 1, char(2), char(3)) AS highlighted
423
+ FROM chunks
424
+ JOIN sources ON sources.id = chunks.source_id
425
+ WHERE chunks MATCH ? AND sources.label = ? AND chunks.content_type = ?
426
+ ORDER BY rank
427
+ LIMIT ?
380
428
  `);
381
429
  this.#stmtSearchTrigramContentType = this.#db.prepare(`
382
430
  SELECT
@@ -405,6 +453,20 @@ export class ContentStore {
405
453
  WHERE chunks_trigram MATCH ? AND sources.label LIKE ? AND chunks_trigram.content_type = ?
406
454
  ORDER BY rank
407
455
  LIMIT ?
456
+ `);
457
+ this.#stmtSearchTrigramExactContentType = this.#db.prepare(`
458
+ SELECT
459
+ chunks_trigram.title,
460
+ chunks_trigram.content,
461
+ chunks_trigram.content_type,
462
+ sources.label,
463
+ bm25(chunks_trigram, 5.0, 1.0) AS rank,
464
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
465
+ FROM chunks_trigram
466
+ JOIN sources ON sources.id = chunks_trigram.source_id
467
+ WHERE chunks_trigram MATCH ? AND sources.label = ? AND chunks_trigram.content_type = ?
468
+ ORDER BY rank
469
+ LIMIT ?
408
470
  `);
409
471
  // Fuzzy path
410
472
  this.#stmtFuzzyVocab = this.#db.prepare("SELECT word FROM vocabulary WHERE length(word) BETWEEN ? AND ?");
@@ -514,17 +576,34 @@ export class ContentStore {
514
576
  };
515
577
  }
516
578
  // ── Search ──
517
- search(query, limit = 3, source, mode = "AND", contentType) {
579
+ #mapSearchRows(rows) {
580
+ return rows.map((r) => ({
581
+ title: r.title,
582
+ content: r.content,
583
+ source: r.label,
584
+ rank: r.rank,
585
+ contentType: r.content_type,
586
+ highlighted: r.highlighted,
587
+ }));
588
+ }
589
+ #sourceFilterParam(source, sourceMatchMode) {
590
+ return sourceMatchMode === "exact" ? source : `%${source}%`;
591
+ }
592
+ search(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
518
593
  const sanitized = sanitizeQuery(query, mode);
519
594
  let stmt;
520
595
  let params;
521
596
  if (source && contentType) {
522
- stmt = this.#stmtSearchPorterFilteredContentType;
523
- params = [sanitized, `%${source}%`, contentType, limit];
597
+ stmt = sourceMatchMode === "exact"
598
+ ? this.#stmtSearchPorterExactContentType
599
+ : this.#stmtSearchPorterFilteredContentType;
600
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), contentType, limit];
524
601
  }
525
602
  else if (source) {
526
- stmt = this.#stmtSearchPorterFiltered;
527
- params = [sanitized, `%${source}%`, limit];
603
+ stmt = sourceMatchMode === "exact"
604
+ ? this.#stmtSearchPorterExact
605
+ : this.#stmtSearchPorterFiltered;
606
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), limit];
528
607
  }
529
608
  else if (contentType) {
530
609
  stmt = this.#stmtSearchPorterContentType;
@@ -534,30 +613,26 @@ export class ContentStore {
534
613
  stmt = this.#stmtSearchPorter;
535
614
  params = [sanitized, limit];
536
615
  }
537
- const rows = stmt.all(...params);
538
- return rows.map((r) => ({
539
- title: r.title,
540
- content: r.content,
541
- source: r.label,
542
- rank: r.rank,
543
- contentType: r.content_type,
544
- highlighted: r.highlighted,
545
- }));
616
+ return this.#mapSearchRows(stmt.all(...params));
546
617
  }
547
618
  // ── Trigram Search (Layer 2) ──
548
- searchTrigram(query, limit = 3, source, mode = "AND", contentType) {
619
+ searchTrigram(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
549
620
  const sanitized = sanitizeTrigramQuery(query, mode);
550
621
  if (!sanitized)
551
622
  return [];
552
623
  let stmt;
553
624
  let params;
554
625
  if (source && contentType) {
555
- stmt = this.#stmtSearchTrigramFilteredContentType;
556
- params = [sanitized, `%${source}%`, contentType, limit];
626
+ stmt = sourceMatchMode === "exact"
627
+ ? this.#stmtSearchTrigramExactContentType
628
+ : this.#stmtSearchTrigramFilteredContentType;
629
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), contentType, limit];
557
630
  }
558
631
  else if (source) {
559
- stmt = this.#stmtSearchTrigramFiltered;
560
- params = [sanitized, `%${source}%`, limit];
632
+ stmt = sourceMatchMode === "exact"
633
+ ? this.#stmtSearchTrigramExact
634
+ : this.#stmtSearchTrigramFiltered;
635
+ params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), limit];
561
636
  }
562
637
  else if (contentType) {
563
638
  stmt = this.#stmtSearchTrigramContentType;
@@ -567,15 +642,7 @@ export class ContentStore {
567
642
  stmt = this.#stmtSearchTrigram;
568
643
  params = [sanitized, limit];
569
644
  }
570
- const rows = stmt.all(...params);
571
- return rows.map((r) => ({
572
- title: r.title,
573
- content: r.content,
574
- source: r.label,
575
- rank: r.rank,
576
- contentType: r.content_type,
577
- highlighted: r.highlighted,
578
- }));
645
+ return this.#mapSearchRows(stmt.all(...params));
579
646
  }
580
647
  // ── Fuzzy Correction (Layer 3) ──
581
648
  fuzzyCorrect(query) {
@@ -598,11 +665,11 @@ export class ContentStore {
598
665
  return bestDist <= maxDist ? bestWord : null;
599
666
  }
600
667
  // ── Reciprocal Rank Fusion (Cormack et al. 2009) ──
601
- #rrfSearch(query, limit, source, contentType) {
668
+ #rrfSearch(query, limit, source, contentType, sourceMatchMode = "like") {
602
669
  const K = 60; // Standard RRF constant
603
670
  const fetchLimit = Math.max(limit * 2, 10);
604
- const porterResults = this.search(query, fetchLimit, source, "OR", contentType);
605
- const trigramResults = this.searchTrigram(query, fetchLimit, source, "OR", contentType);
671
+ const porterResults = this.search(query, fetchLimit, source, "OR", contentType, sourceMatchMode);
672
+ const trigramResults = this.searchTrigram(query, fetchLimit, source, "OR", contentType, sourceMatchMode);
606
673
  const scoreMap = new Map();
607
674
  const key = (r) => `${r.source}::${r.title}`;
608
675
  for (const [i, r] of porterResults.entries()) {
@@ -655,9 +722,9 @@ export class ContentStore {
655
722
  .map(({ result }) => result);
656
723
  }
657
724
  // ── Unified Fallback Search ──
658
- searchWithFallback(query, limit = 3, source, contentType) {
725
+ searchWithFallback(query, limit = 3, source, contentType, sourceMatchMode = "like") {
659
726
  // Step 1: RRF fusion (porter OR + trigram OR → merge)
660
- const rrfResults = this.#rrfSearch(query, limit, source, contentType);
727
+ const rrfResults = this.#rrfSearch(query, limit, source, contentType, sourceMatchMode);
661
728
  if (rrfResults.length > 0) {
662
729
  const reranked = this.#applyProximityReranking(rrfResults, query);
663
730
  return reranked.map((r) => ({ ...r, matchLayer: "rrf" }));
@@ -672,7 +739,7 @@ export class ContentStore {
672
739
  const correctedWords = words.map((w) => this.fuzzyCorrect(w) ?? w);
673
740
  const correctedQuery = correctedWords.join(" ");
674
741
  if (correctedQuery !== original) {
675
- const fuzzyResults = this.#rrfSearch(correctedQuery, limit, source, contentType);
742
+ const fuzzyResults = this.#rrfSearch(correctedQuery, limit, source, contentType, sourceMatchMode);
676
743
  if (fuzzyResults.length > 0) {
677
744
  const reranked = this.#applyProximityReranking(fuzzyResults, correctedQuery);
678
745
  return reranked.map((r) => ({ ...r, matchLayer: "rrf-fuzzy" }));