md-semantic-search 0.5.0 → 1.0.2

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.
Files changed (52) hide show
  1. package/README.md +44 -1
  2. package/bin/cli.mjs +144 -30
  3. package/dist/binary-format.d.ts +25 -0
  4. package/dist/binary-format.js +178 -0
  5. package/dist/core.d.ts +3 -2
  6. package/dist/core.js +26 -31
  7. package/dist/federation.d.ts +15 -0
  8. package/dist/federation.js +94 -0
  9. package/dist/filter.d.ts +29 -0
  10. package/dist/filter.js +317 -0
  11. package/dist/index-format.js +1 -1
  12. package/dist/index.d.ts +9 -1
  13. package/dist/index.js +8 -0
  14. package/dist/indexer.d.ts +13 -0
  15. package/dist/indexer.js +21 -7
  16. package/dist/markdown-parser.d.ts +1 -0
  17. package/dist/markdown-parser.js +71 -12
  18. package/dist/mcp.d.ts +54 -6
  19. package/dist/mcp.js +59 -11
  20. package/dist/quantization.d.ts +24 -0
  21. package/dist/quantization.js +85 -0
  22. package/dist/rag.d.ts +49 -0
  23. package/dist/rag.js +128 -0
  24. package/dist/search.d.ts +20 -6
  25. package/dist/search.js +175 -23
  26. package/dist/serve.js +44 -3
  27. package/dist/summarize.d.ts +16 -0
  28. package/dist/summarize.js +82 -0
  29. package/dist/tui.d.ts +1 -0
  30. package/dist/tui.js +36 -13
  31. package/dist/watcher.d.ts +11 -0
  32. package/dist/watcher.js +138 -0
  33. package/dist/wikilinks.d.ts +22 -0
  34. package/dist/wikilinks.js +100 -0
  35. package/package.json +3 -1
  36. package/src/binary-format.ts +216 -0
  37. package/src/core.ts +45 -37
  38. package/src/federation.ts +118 -0
  39. package/src/filter.ts +368 -0
  40. package/src/index-format.ts +1 -1
  41. package/src/index.ts +39 -1
  42. package/src/indexer.ts +42 -10
  43. package/src/markdown-parser.ts +80 -10
  44. package/src/mcp.ts +57 -11
  45. package/src/quantization.ts +93 -0
  46. package/src/rag.ts +189 -0
  47. package/src/search.ts +202 -25
  48. package/src/serve.ts +46 -4
  49. package/src/summarize.ts +98 -0
  50. package/src/tui.ts +36 -12
  51. package/src/watcher.ts +157 -0
  52. package/src/wikilinks.ts +132 -0
package/src/search.ts CHANGED
@@ -8,6 +8,10 @@ import { bm25Scores, matchingTerms, tokenize, fuzzyTitleAliasScores, LexicalInde
8
8
  import { collapseResults } from './collapse.js';
9
9
  import { DocumentMetadata } from './frontmatter.js';
10
10
  import { trainIVF, searchIVFCandidates, deserializeIVF, IVFIndex, ANN_THRESHOLD, DEFAULT_NPROBE } from './ivf.js';
11
+ import { buildRelationshipGraph, computePageRank, expandGraphNeighborhood, RelationshipGraph } from './wikilinks.js';
12
+ import { evaluateFilter, FilterNode } from './filter.js';
13
+ import { deserializeBinaryIndex } from './binary-format.js';
14
+ import { dequantizeFromInt8 } from './quantization.js';
11
15
 
12
16
  export { tokenize } from './lexical.js';
13
17
 
@@ -36,15 +40,22 @@ export function keywordScores(chunks: IndexChunk[], query: string): number[] {
36
40
  });
37
41
  }
38
42
 
39
- export function rrf(rankings: Array<Array<{ idx: number; score: number }>>, k = 60): Map<number, number> {
43
+ export function rrf(
44
+ rankings: Array<Array<{ idx: number; score: number }>>,
45
+ k = 60,
46
+ weights?: number[]
47
+ ): Map<number, number> {
40
48
  const fused = new Map<number, number>();
41
- for (const ranking of rankings) {
49
+ for (let rankingIdx = 0; rankingIdx < rankings.length; rankingIdx++) {
50
+ const ranking = rankings[rankingIdx];
51
+ const weight = weights ? (weights[rankingIdx] ?? 1) : 1;
52
+ if (weight <= 0) continue;
42
53
  const sorted = [...ranking].sort((a, b) => b.score - a.score);
43
54
  let scoreRank = 0;
44
55
  sorted.forEach((item, rank) => {
45
56
  if (item.score <= 0) return;
46
57
  if (rank > 0 && item.score < sorted[rank - 1].score) scoreRank = rank;
47
- fused.set(item.idx, (fused.get(item.idx) || 0) + 1 / (k + scoreRank + 1));
58
+ fused.set(item.idx, (fused.get(item.idx) || 0) + weight / (k + scoreRank + 1));
48
59
  });
49
60
  }
50
61
  return fused;
@@ -155,7 +166,7 @@ export interface RuntimeChunk {
155
166
  startLine?: number;
156
167
  endLine?: number;
157
168
  meta?: DocumentMetadata;
158
- vec: Float32Array;
169
+ vec: Float32Array | Int8Array;
159
170
  }
160
171
 
161
172
  export interface RuntimeIndexState {
@@ -167,29 +178,66 @@ export interface RuntimeIndexState {
167
178
  model: ModelDescriptor;
168
179
  queryCache: QueryEmbeddingCache;
169
180
  ivf?: IVFIndex;
181
+ graph?: RelationshipGraph;
182
+ pageRank?: Map<string, number>;
183
+ }
184
+
185
+ export function getRuntimeGraph(runtime: RuntimeIndexState): { graph: RelationshipGraph; pageRank: Map<string, number> } {
186
+ if (runtime.graph && runtime.pageRank) {
187
+ return { graph: runtime.graph, pageRank: runtime.pageRank };
188
+ }
189
+ const docsMap = new Map<string, { file: string; title?: string; meta?: DocumentMetadata; text?: string }>();
190
+ for (const chunk of runtime.chunks) {
191
+ const existing = docsMap.get(chunk.file);
192
+ if (!existing) {
193
+ docsMap.set(chunk.file, {
194
+ file: chunk.file,
195
+ title: chunk.title,
196
+ meta: chunk.meta,
197
+ text: chunk.text,
198
+ });
199
+ } else {
200
+ existing.text = (existing.text || '') + '\n' + chunk.text;
201
+ }
202
+ }
203
+ const docs = Array.from(docsMap.values());
204
+ const graph = buildRelationshipGraph(docs);
205
+ const pageRank = computePageRank(graph);
206
+ runtime.graph = graph;
207
+ runtime.pageRank = pageRank;
208
+ return { graph, pageRank };
170
209
  }
171
210
 
172
211
  const runtimeIndexStates = new WeakMap<IndexFile, RuntimeIndexState>();
173
212
 
174
213
  function snapshotRuntimeIndex(
175
214
  index: IndexFile,
176
- validation: { schema: number; dim: number | undefined; model: ModelDescriptor; ivf?: IVFIndex }
215
+ validation: {
216
+ schema: number;
217
+ dim: number | undefined;
218
+ model: ModelDescriptor;
219
+ ivf?: IVFIndex;
220
+ rawVectorsBuffer?: Float32Array | Int8Array;
221
+ }
177
222
  ): RuntimeIndexState {
178
223
  const dim = validation.dim || (index.chunks[0]?.vec?.length ?? 0);
179
224
  const count = index.chunks.length;
180
- const vectorsBuffer = dim > 0 ? new Float32Array(count * dim) : null;
225
+ const isZeroCopy = !!validation.rawVectorsBuffer;
226
+ const vectorsBuffer = validation.rawVectorsBuffer ?? (dim > 0 ? new Float32Array(count * dim) : null);
181
227
 
182
228
  const chunks: RuntimeChunk[] = index.chunks.map((chunk, i) => {
183
- let vec: Float32Array;
229
+ let vec: Float32Array | Int8Array;
184
230
  if (vectorsBuffer && dim > 0) {
185
231
  const offset = i * dim;
186
- if (chunk.vec instanceof Float32Array) {
187
- vectorsBuffer.set(chunk.vec, offset);
188
- } else {
189
- const arr = chunk.vec as number[];
190
- for (let j = 0; j < dim; j++) vectorsBuffer[offset + j] = arr[j];
232
+ if (!isZeroCopy) {
233
+ if (chunk.vec instanceof Float32Array || chunk.vec instanceof Int8Array) {
234
+ (vectorsBuffer as any).set(chunk.vec, offset);
235
+ } else {
236
+ const arr = chunk.vec as number[];
237
+ for (let j = 0; j < dim; j++) (vectorsBuffer as any)[offset + j] = arr[j];
238
+ }
191
239
  }
192
- vec = vectorsBuffer.subarray(offset, offset + dim);
240
+ vec = (vectorsBuffer as any).subarray(offset, offset + dim);
193
241
  } else {
194
242
  vec = Float32Array.from(chunk.vec as number[]);
195
243
  }
@@ -280,7 +328,73 @@ function warnIfStale(index: IndexFile): void {
280
328
  }
281
329
 
282
330
  export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDescriptor } {
331
+ const vectorsBinPath = path.join(indexDir, 'vectors.bin');
283
332
  const vectorsPath = path.join(indexDir, 'vectors.json');
333
+
334
+ let useBinary = false;
335
+ if (fs.existsSync(vectorsBinPath)) {
336
+ if (!fs.existsSync(vectorsPath)) {
337
+ useBinary = true;
338
+ } else {
339
+ try {
340
+ const binStat = fs.statSync(vectorsBinPath);
341
+ const jsonStat = fs.statSync(vectorsPath);
342
+ if (binStat.mtimeMs >= jsonStat.mtimeMs) {
343
+ useBinary = true;
344
+ }
345
+ } catch {
346
+ useBinary = false;
347
+ }
348
+ }
349
+ }
350
+
351
+ if (useBinary) {
352
+ try {
353
+ const binBuffer = fs.readFileSync(vectorsBinPath);
354
+ const shaBinPath = path.join(indexDir, 'vectors.bin.sha256');
355
+ if (fs.existsSync(shaBinPath)) {
356
+ const expectedHash = fs.readFileSync(shaBinPath, 'utf8').trim().split(/\s+/)[0];
357
+ const actualHash = crypto.createHash('sha256').update(binBuffer).digest('hex');
358
+ if (expectedHash && actualHash !== expectedHash) {
359
+ throw new Error('vectors.bin integrity check failed (SHA-256 mismatch) — run `mdss index` to rebuild.');
360
+ }
361
+ }
362
+ const deserialized = deserializeBinaryIndex(binBuffer);
363
+ const validated = validateIndexEnvelope(deserialized, vectorsBinPath, { encoding: 'loaded', validateVectors: true });
364
+ const index = validated.index as IndexFile;
365
+ const { model, dim: validatedDim } = validated;
366
+ const expectedDim = validatedDim ?? deserialized.dim;
367
+
368
+ warnIfStale(index);
369
+
370
+ const ivfPath = path.join(indexDir, 'ivf.json');
371
+ let ivf: IVFIndex | undefined;
372
+ if (fs.existsSync(ivfPath)) {
373
+ try {
374
+ const raw = JSON.parse(fs.readFileSync(ivfPath, 'utf8'));
375
+ ivf = deserializeIVF(raw);
376
+ } catch {}
377
+ }
378
+
379
+ runtimeIndexStates.set(
380
+ index,
381
+ snapshotRuntimeIndex(index, {
382
+ schema: validated.schema,
383
+ dim: expectedDim,
384
+ model,
385
+ ivf,
386
+ rawVectorsBuffer: deserialized.rawVectorsBuffer,
387
+ })
388
+ );
389
+
390
+ return { index, model };
391
+ } catch (e: any) {
392
+ if (e.message?.includes('integrity check failed')) throw e;
393
+ if (!fs.existsSync(vectorsPath)) throw e;
394
+ // If vectors.bin was corrupt or unreadable, fall through to vectors.json fallback
395
+ }
396
+ }
397
+
284
398
  if (!fs.existsSync(vectorsPath)) {
285
399
  throw new Error(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
286
400
  }
@@ -358,8 +472,13 @@ export function loadIndex(indexDir: string): { index: IndexFile; model: ModelDes
358
472
 
359
473
  export const MAX_QUERY_LENGTH = 2048;
360
474
 
475
+ export interface LoadedIndex {
476
+ index: IndexFile;
477
+ model: ModelDescriptor;
478
+ }
479
+
361
480
  export interface SearchOptions {
362
- loaded: { index: IndexFile; model: ModelDescriptor };
481
+ loaded: LoadedIndex;
363
482
  cacheDir: string;
364
483
  query: string;
365
484
  k?: number;
@@ -383,6 +502,8 @@ export interface SearchOptions {
383
502
  useQueryCache?: boolean;
384
503
  ann?: boolean;
385
504
  nprobe?: number;
505
+ graphBoost?: number;
506
+ filter?: string | FilterNode;
386
507
  }
387
508
 
388
509
  export interface SearchResultHit {
@@ -394,10 +515,13 @@ export interface SearchResultHit {
394
515
  matches: string[];
395
516
  snippet: string;
396
517
  meta?: DocumentMetadata;
518
+ graphScore?: number;
397
519
  explain?: {
398
520
  cosine: number;
399
521
  rrfScore: number;
400
522
  bm25Weights: { title: number; aliases: number; headingPath: number; body: number };
523
+ graphScore?: number;
524
+ pageRank?: number;
401
525
  };
402
526
  rerankScore?: number;
403
527
  }
@@ -420,8 +544,11 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
420
544
  status,
421
545
  canonicalOnly,
422
546
  custom,
547
+ filter: filterExpr,
423
548
  } = opts;
424
549
 
550
+ const graphBoost = typeof opts.graphBoost === 'number' && Number.isFinite(opts.graphBoost) ? opts.graphBoost : 0;
551
+
425
552
  if (typeof query === 'string' && query.length > MAX_QUERY_LENGTH) {
426
553
  throw new Error(`query exceeds maximum length of ${MAX_QUERY_LENGTH} characters`);
427
554
  }
@@ -445,7 +572,8 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
445
572
  type !== undefined ||
446
573
  status !== undefined ||
447
574
  canonicalOnly !== undefined ||
448
- custom !== undefined;
575
+ custom !== undefined ||
576
+ filterExpr !== undefined;
449
577
 
450
578
  if (pathRes.length > 0 || sinceMs !== undefined || hasMetaFilter) {
451
579
  if (sinceMs !== undefined && !db) {
@@ -466,6 +594,9 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
466
594
  }
467
595
  if (m < sinceMs) return false;
468
596
  }
597
+ if (filterExpr !== undefined) {
598
+ if (!evaluateFilter(filterExpr, chunk.meta, { file: chunk.file, title: chunk.title })) return false;
599
+ }
469
600
  if (tag !== undefined) {
470
601
  const reqTags = (Array.isArray(tag) ? tag : [tag]).map((t) => String(t).toLowerCase().replace(/^#/, ''));
471
602
  const chunkTags = chunk.meta?.tags || [];
@@ -509,7 +640,7 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
509
640
  const useAnn = opts.ann ?? (runtime.ivf !== undefined && candidates.length >= ANN_THRESHOLD);
510
641
  if (useAnn && candidates.length >= ANN_THRESHOLD) {
511
642
  if (!runtime.ivf) {
512
- const rawVecs = chunks.map((c) => c.vec);
643
+ const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : dequantizeFromInt8(c.vec)));
513
644
  runtime.ivf = trainIVF(rawVecs);
514
645
  }
515
646
  const qVecF32 = qVec instanceof Float32Array ? qVec : Float32Array.from(qVec);
@@ -529,12 +660,45 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
529
660
 
530
661
  const pool = rerank ? rerankPool || Math.max(20, k * 3) : k;
531
662
 
663
+ let graphRanking: Array<{ idx: number; score: number }> = [];
664
+ let graphMap: Map<number, number> | undefined;
665
+ if (graphBoost > 0) {
666
+ const { graph, pageRank } = getRuntimeGraph(runtime);
667
+ const seedFilesMap = new Map<string, number>();
668
+ for (const s of semantic) {
669
+ if (s.score > 0) {
670
+ const file = chunks[s.idx].file;
671
+ const curr = seedFilesMap.get(file) || 0;
672
+ if (s.score > curr) seedFilesMap.set(file, s.score);
673
+ }
674
+ }
675
+ const seedFiles = Array.from(seedFilesMap.entries()).map(([file, score]) => ({ file, score }));
676
+ const propagationScores = expandGraphNeighborhood(graph, seedFiles, { maxDepth: 2, decay: 0.5 });
677
+
678
+ graphMap = new Map<number, number>();
679
+ graphRanking = candidates.map(({ chunk, idx }) => {
680
+ const pr = pageRank.get(chunk.file) || 0;
681
+ const prop = propagationScores.get(chunk.file) || 0;
682
+ const totalGraph = pr + prop;
683
+ graphMap!.set(idx, totalGraph);
684
+ return { idx, score: totalGraph };
685
+ });
686
+ }
687
+
532
688
  let ranked: Array<{ idx: number; fscore: number; cos: number; rerank?: number }>;
533
689
  if (semanticOnly) {
534
- ranked = [...semantic]
535
- .sort((a, b) => b.score - a.score)
536
- .slice(0, pool)
537
- .map((s) => ({ idx: s.idx, fscore: s.score, cos: s.score }));
690
+ if (graphBoost > 0) {
691
+ const fused = rrf([semantic, graphRanking], 60, [1, graphBoost]);
692
+ ranked = [...fused.entries()]
693
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx)! }))
694
+ .sort((a, b) => b.fscore - a.fscore)
695
+ .slice(0, pool);
696
+ } else {
697
+ ranked = [...semantic]
698
+ .sort((a, b) => b.score - a.score)
699
+ .slice(0, pool)
700
+ .map((s) => ({ idx: s.idx, fscore: s.score, cos: s.score }));
701
+ }
538
702
  } else {
539
703
  const kw =
540
704
  lexicalState.kind === 'persisted-bm25'
@@ -551,11 +715,19 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
551
715
  score,
552
716
  }));
553
717
 
554
- const fused = rrf([semantic, kw, fuzzy]);
555
- ranked = [...fused.entries()]
556
- .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx)! }))
557
- .sort((a, b) => b.fscore - a.fscore)
558
- .slice(0, pool);
718
+ if (graphBoost > 0) {
719
+ const fused = rrf([semantic, kw, fuzzy, graphRanking], 60, [1, 1, 1, graphBoost]);
720
+ ranked = [...fused.entries()]
721
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx)! }))
722
+ .sort((a, b) => b.fscore - a.fscore)
723
+ .slice(0, pool);
724
+ } else {
725
+ const fused = rrf([semantic, kw, fuzzy]);
726
+ ranked = [...fused.entries()]
727
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx)! }))
728
+ .sort((a, b) => b.fscore - a.fscore)
729
+ .slice(0, pool);
730
+ }
559
731
  }
560
732
 
561
733
  if (rerank && ranked.length > 0) {
@@ -590,6 +762,8 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
590
762
  lexicalState.kind === 'persisted-bm25'
591
763
  ? matchingTerms(lexicalState.lexical, queryTerms, r.idx)
592
764
  : queryTerms.filter((term) => tokenSet(c as any).has(term));
765
+ const gScore = graphMap?.get(r.idx);
766
+ const prScore = runtime.pageRank?.get(c.file);
593
767
  return {
594
768
  file: c.file,
595
769
  title: c.title,
@@ -599,12 +773,15 @@ export async function searchIndex(opts: SearchOptions): Promise<SearchResultHit[
599
773
  matches,
600
774
  snippet: c.text.replace(/\s+/g, ' ').slice(0, 220),
601
775
  ...(hasMetaFields(c.meta) ? { meta: c.meta } : {}),
776
+ ...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
602
777
  ...(explain
603
778
  ? {
604
779
  explain: {
605
780
  cosine: +r.cos.toFixed(4),
606
781
  rrfScore: +r.fscore.toFixed(4),
607
782
  bm25Weights: { title: 3.0, aliases: 3.0, headingPath: 1.8, body: 1.0 },
783
+ ...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
784
+ ...(prScore !== undefined ? { pageRank: +prScore.toFixed(4) } : {}),
608
785
  },
609
786
  }
610
787
  : {}),
package/src/serve.ts CHANGED
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
  import { buildIndex } from './indexer.js';
11
11
  import { loadIndex, searchIndex } from './search.js';
12
12
  import { walkMarkdown, assertSafePath, ModelDescriptor } from './core.js';
13
+ import { createFileWatcher, FileWatcher } from './watcher.js';
13
14
 
14
15
  const DEFAULT_PORT = 8747;
15
16
  const DEFAULT_HOST = '127.0.0.1';
@@ -91,14 +92,20 @@ export async function createServe(opts: CreateServeOptions): Promise<{
91
92
  healthPublic,
92
93
  };
93
94
 
95
+ const sockets = new Set<import('node:net').Socket>();
94
96
  const server = http.createServer((req, res) => {
95
97
  handleRequest(req, res, state).catch((e) => {
96
98
  json(res, 500, { error: e.message });
97
99
  });
98
100
  });
101
+ server.on('connection', (socket) => {
102
+ sockets.add(socket);
103
+ socket.on('close', () => sockets.delete(socket));
104
+ });
99
105
 
100
106
  let stopped = false;
101
107
  let timer: NodeJS.Timeout | null = null;
108
+ let resolveTimer: (() => void) | null = null;
102
109
 
103
110
  const reload = async () => {
104
111
  if (!db) return false;
@@ -190,14 +197,22 @@ export async function createServe(opts: CreateServeOptions): Promise<{
190
197
  return out;
191
198
  };
192
199
 
200
+ let lastFingerprint = '';
201
+
193
202
  const watchLoop = async () => {
194
203
  let indexedHashes = readIndexedHashes();
195
- let lastFingerprint = treeContentFingerprint(scanTree());
204
+ lastFingerprint = treeContentFingerprint(scanTree());
196
205
  let lastChangeAt = 0;
197
206
  let pending = false;
198
207
  while (!stopped) {
199
- await new Promise((r) => {
200
- timer = setTimeout(r, watchInterval);
208
+ await new Promise<void>((r) => {
209
+ resolveTimer = r;
210
+ timer = setTimeout(() => {
211
+ timer = null;
212
+ resolveTimer = null;
213
+ r();
214
+ }, watchInterval);
215
+ timer.unref?.();
201
216
  });
202
217
  if (stopped) return;
203
218
  const tree = scanTree();
@@ -228,14 +243,39 @@ export async function createServe(opts: CreateServeOptions): Promise<{
228
243
  }
229
244
  };
230
245
 
246
+ let nativeWatcher: FileWatcher | null = null;
247
+
231
248
  if (watch) {
232
249
  if (!db) throw new Error('serve --watch requires --db');
250
+ nativeWatcher = createFileWatcher(db, () => {
251
+ // Prompt watchLoop settle trigger on native event
252
+ lastFingerprint = '';
253
+ }, {
254
+ debounceMs: watchDelay,
255
+ ignore,
256
+ log,
257
+ });
233
258
  watchLoop();
234
259
  }
235
260
 
236
261
  const close = async (): Promise<void> => {
237
262
  stopped = true;
238
- if (timer) clearTimeout(timer);
263
+ if (timer) {
264
+ clearTimeout(timer);
265
+ timer = null;
266
+ }
267
+ if (resolveTimer) {
268
+ resolveTimer();
269
+ resolveTimer = null;
270
+ }
271
+ if (nativeWatcher) nativeWatcher.close();
272
+ server.closeAllConnections?.();
273
+ for (const socket of sockets) {
274
+ try {
275
+ socket.destroy();
276
+ } catch {}
277
+ }
278
+ sockets.clear();
239
279
  await new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
240
280
  };
241
281
 
@@ -329,6 +369,8 @@ async function handleRequest(req: http.IncomingMessage, res: http.ServerResponse
329
369
  maxPerFile: payload.maxPerFile || payload.maxPerDoc,
330
370
  ann: payload.ann,
331
371
  nprobe: payload.nprobe,
372
+ graphBoost: payload.graphBoost,
373
+ filter: payload.filter,
332
374
  });
333
375
  json(res, 200, { query, k, count: results.length, results });
334
376
  } catch (e: any) {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Automated Note Summarization & Semantic Keyword Tagging Pipeline (issue #103).
3
+ *
4
+ * Implements zero-dependency, deterministic extractive summarization and keyphrase
5
+ * tag extraction to enrich note chunks with discoverable semantic metadata.
6
+ */
7
+ import { tokenize } from './lexical.js';
8
+
9
+ export interface NoteAnalysis {
10
+ tags: string[];
11
+ summary: string;
12
+ }
13
+
14
+ /**
15
+ * Extracts salient topical tags from Markdown text using term frequency and position weighting.
16
+ */
17
+ export function extractKeyphraseTags(text: string, maxTags: number = 5): string[] {
18
+ if (!text || !text.trim()) return [];
19
+
20
+ const lines = text.split('\n');
21
+ const termScores = new Map<string, number>();
22
+
23
+ lines.forEach((line, lineIdx) => {
24
+ const isHeading = /^#{1,6}\s+/.test(line);
25
+ const weight = isHeading ? 3.0 : lineIdx < 3 ? 1.5 : 1.0;
26
+ const tokens = tokenize(line);
27
+
28
+ for (const tok of tokens) {
29
+ if (tok.length < 3) continue;
30
+ // Skip purely numeric tokens
31
+ if (/^\d+$/.test(tok)) continue;
32
+
33
+ const current = termScores.get(tok) || 0;
34
+ termScores.set(tok, current + weight);
35
+ }
36
+ });
37
+
38
+ const sorted = Array.from(termScores.entries())
39
+ .sort((a, b) => b[1] - a[1])
40
+ .slice(0, maxTags)
41
+ .map(([term]) => term);
42
+
43
+ return sorted;
44
+ }
45
+
46
+ /**
47
+ * Generates a concise extractive summary (1-2 sentences) representing the salient points of a note.
48
+ */
49
+ export function summarizePassage(text: string, maxSentences: number = 2): string {
50
+ if (!text || !text.trim()) return '';
51
+
52
+ // Clean Markdown formatting
53
+ const clean = text
54
+ .replace(/^#{1,6}\s+.*$/gm, '')
55
+ .replace(/```[\s\S]*?```/g, '')
56
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
57
+ .replace(/[*_`~]/g, '')
58
+ .trim();
59
+
60
+ const sentences = clean
61
+ .split(/(?<=[.?!])\s+|\n\n+/)
62
+ .map((s) => s.trim())
63
+ .filter((s) => s.length >= 20 && s.length <= 300);
64
+
65
+ if (sentences.length <= maxSentences) {
66
+ return sentences.join(' ');
67
+ }
68
+
69
+ const salientTags = new Set(extractKeyphraseTags(clean, 8));
70
+
71
+ const scored = sentences.map((sent, index) => {
72
+ const tokens = tokenize(sent);
73
+ let tagMatches = 0;
74
+ for (const t of tokens) {
75
+ if (salientTags.has(t)) tagMatches++;
76
+ }
77
+ // Favor earlier sentences and sentences containing salient keywords
78
+ const posScore = 1.0 / (index + 1);
79
+ const salienceScore = tagMatches / (tokens.length + 1);
80
+ const score = posScore * 0.4 + salienceScore * 0.6;
81
+ return { sent, score, index };
82
+ });
83
+
84
+ scored.sort((a, b) => b.score - a.score);
85
+ const selected = scored.slice(0, maxSentences).sort((a, b) => a.index - b.index);
86
+
87
+ return selected.map((s) => s.sent).join(' ');
88
+ }
89
+
90
+ /**
91
+ * Analyzes a Markdown note to produce automated semantic tags and summary.
92
+ */
93
+ export function analyzeNote(text: string): NoteAnalysis {
94
+ return {
95
+ tags: extractKeyphraseTags(text),
96
+ summary: summarizePassage(text),
97
+ };
98
+ }
package/src/tui.ts CHANGED
@@ -2,6 +2,7 @@ import readline from 'node:readline';
2
2
  import { spawn } from 'node:child_process';
3
3
  import path from 'node:path';
4
4
  import { loadIndex, searchIndex, SearchResultHit } from './search.js';
5
+ import { extractAnswerFallback } from './rag.js';
5
6
  import { ModelDescriptor } from './core.js';
6
7
 
7
8
  export interface TuiOptions {
@@ -15,6 +16,7 @@ export interface TuiOptions {
15
16
  path?: string | string[];
16
17
  rerank?: boolean;
17
18
  rerankPool?: number;
19
+ rag?: boolean;
18
20
  embedFn?: any;
19
21
  rerankFn?: any;
20
22
  debounceMs?: number;
@@ -74,6 +76,8 @@ export async function runTui(opts: TuiOptions): Promise<SearchResultHit | null>
74
76
  }, opts.debounceMs ?? 300);
75
77
  };
76
78
 
79
+ let showRag = Boolean(opts.rag);
80
+
77
81
  const render = () => {
78
82
  const rows = process.stdout.rows || 24;
79
83
  const cols = process.stdout.columns || 80;
@@ -84,11 +88,12 @@ export async function runTui(opts: TuiOptions): Promise<SearchResultHit | null>
84
88
 
85
89
  // Header
86
90
  const searchStatus = searching ? ' [searching...]' : '';
87
- const headerStr = ` mdss interactive search | Query: ${query}${searchStatus}`;
91
+ const modeLabel = showRag ? 'RAG QA & Synthesis' : 'search';
92
+ const headerStr = ` mdss interactive ${modeLabel} | Query: ${query}${searchStatus}`;
88
93
  buf += `\x1b[7m${headerStr.padEnd(cols).slice(0, cols)}\x1b[0m\n`;
89
94
 
90
95
  // Calculate layout heights
91
- const listHeight = Math.max(4, Math.floor((rows - 4) * 0.4));
96
+ const listHeight = Math.max(3, Math.floor((rows - 4) * 0.35));
92
97
  const previewHeight = Math.max(4, rows - 4 - listHeight);
93
98
 
94
99
  // Render list
@@ -111,25 +116,38 @@ export async function runTui(opts: TuiOptions): Promise<SearchResultHit | null>
111
116
  }
112
117
  }
113
118
 
114
- // Render Preview
115
- buf += `\x1b[1m--- Passage Preview ---\x1b[0m\n`;
116
- const selectedHit = results[selectedIndex];
117
- if (selectedHit) {
118
- const textLines = selectedHit.snippet.split('\n');
119
+ // Render Preview or RAG synthesis
120
+ if (showRag) {
121
+ buf += `\x1b[1m--- Grounded Answer Synthesis & Citations ---\x1b[0m\n`;
122
+ const answer = extractAnswerFallback(query, results);
123
+ const answerLines = answer.split('\n');
119
124
  for (let i = 0; i < previewHeight; i++) {
120
- if (i < textLines.length) {
121
- buf += ` ${textLines[i].slice(0, cols - 3)}\n`;
125
+ if (i < answerLines.length) {
126
+ buf += ` ${answerLines[i].slice(0, cols - 3)}\n`;
122
127
  } else {
123
128
  buf += '\n';
124
129
  }
125
130
  }
126
131
  } else {
127
- buf += ' (no passage selected)\n';
128
- for (let i = 1; i < previewHeight; i++) buf += '\n';
132
+ buf += `\x1b[1m--- Passage Preview ---\x1b[0m\n`;
133
+ const selectedHit = results[selectedIndex];
134
+ if (selectedHit) {
135
+ const textLines = selectedHit.snippet.split('\n');
136
+ for (let i = 0; i < previewHeight; i++) {
137
+ if (i < textLines.length) {
138
+ buf += ` ${textLines[i].slice(0, cols - 3)}\n`;
139
+ } else {
140
+ buf += '\n';
141
+ }
142
+ }
143
+ } else {
144
+ buf += ' (no passage selected)\n';
145
+ for (let i = 1; i < previewHeight; i++) buf += '\n';
146
+ }
129
147
  }
130
148
 
131
149
  // Footer
132
- const footerStr = ' [Up/Down/j/k]: Navigate | [Enter]: Open in Editor | [Esc/q/Ctrl+C]: Quit';
150
+ const footerStr = ' [Tab]: Toggle RAG/Passage | [Up/Down]: Select | [Enter]: Open | [Esc]: Quit';
133
151
  buf += `\x1b[7m${footerStr.padEnd(cols).slice(0, cols)}\x1b[0m`;
134
152
 
135
153
  process.stdout.write(buf);
@@ -171,6 +189,12 @@ export async function runTui(opts: TuiOptions): Promise<SearchResultHit | null>
171
189
  return;
172
190
  }
173
191
 
192
+ if (key.name === 'tab') {
193
+ showRag = !showRag;
194
+ render();
195
+ return;
196
+ }
197
+
174
198
  if (key.name === 'up' || (key.name === 'k' && key.ctrl)) {
175
199
  if (selectedIndex > 0) {
176
200
  selectedIndex--;