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/dist/search.d.ts CHANGED
@@ -2,6 +2,8 @@ import { ModelDescriptor } from './core.js';
2
2
  import { LexicalIndex } from './lexical.js';
3
3
  import { DocumentMetadata } from './frontmatter.js';
4
4
  import { IVFIndex } from './ivf.js';
5
+ import { RelationshipGraph } from './wikilinks.js';
6
+ import { FilterNode } from './filter.js';
5
7
  export { tokenize } from './lexical.js';
6
8
  export declare const _stats: {
7
9
  corpusTokenizedChars: number;
@@ -10,7 +12,7 @@ export declare function keywordScores(chunks: IndexChunk[], query: string): numb
10
12
  export declare function rrf(rankings: Array<Array<{
11
13
  idx: number;
12
14
  score: number;
13
- }>>, k?: number): Map<number, number>;
15
+ }>>, k?: number, weights?: number[]): Map<number, number>;
14
16
  export interface IndexChunk {
15
17
  file: string;
16
18
  title: string;
@@ -61,7 +63,7 @@ export interface RuntimeChunk {
61
63
  startLine?: number;
62
64
  endLine?: number;
63
65
  meta?: DocumentMetadata;
64
- vec: Float32Array;
66
+ vec: Float32Array | Int8Array;
65
67
  }
66
68
  export interface RuntimeIndexState {
67
69
  schema: number;
@@ -72,17 +74,24 @@ export interface RuntimeIndexState {
72
74
  model: ModelDescriptor;
73
75
  queryCache: QueryEmbeddingCache;
74
76
  ivf?: IVFIndex;
77
+ graph?: RelationshipGraph;
78
+ pageRank?: Map<string, number>;
75
79
  }
80
+ export declare function getRuntimeGraph(runtime: RuntimeIndexState): {
81
+ graph: RelationshipGraph;
82
+ pageRank: Map<string, number>;
83
+ };
76
84
  export declare function loadIndex(indexDir: string): {
77
85
  index: IndexFile;
78
86
  model: ModelDescriptor;
79
87
  };
80
88
  export declare const MAX_QUERY_LENGTH = 2048;
89
+ export interface LoadedIndex {
90
+ index: IndexFile;
91
+ model: ModelDescriptor;
92
+ }
81
93
  export interface SearchOptions {
82
- loaded: {
83
- index: IndexFile;
84
- model: ModelDescriptor;
85
- };
94
+ loaded: LoadedIndex;
86
95
  cacheDir: string;
87
96
  query: string;
88
97
  k?: number;
@@ -106,6 +115,8 @@ export interface SearchOptions {
106
115
  useQueryCache?: boolean;
107
116
  ann?: boolean;
108
117
  nprobe?: number;
118
+ graphBoost?: number;
119
+ filter?: string | FilterNode;
109
120
  }
110
121
  export interface SearchResultHit {
111
122
  file: string;
@@ -116,6 +127,7 @@ export interface SearchResultHit {
116
127
  matches: string[];
117
128
  snippet: string;
118
129
  meta?: DocumentMetadata;
130
+ graphScore?: number;
119
131
  explain?: {
120
132
  cosine: number;
121
133
  rrfScore: number;
@@ -125,6 +137,8 @@ export interface SearchResultHit {
125
137
  headingPath: number;
126
138
  body: number;
127
139
  };
140
+ graphScore?: number;
141
+ pageRank?: number;
128
142
  };
129
143
  rerankScore?: number;
130
144
  }
package/dist/search.js CHANGED
@@ -7,6 +7,10 @@ import { rerankScores } from './rerank.js';
7
7
  import { bm25Scores, matchingTerms, tokenize, fuzzyTitleAliasScores } from './lexical.js';
8
8
  import { collapseResults } from './collapse.js';
9
9
  import { trainIVF, searchIVFCandidates, deserializeIVF, ANN_THRESHOLD, DEFAULT_NPROBE } from './ivf.js';
10
+ import { buildRelationshipGraph, computePageRank, expandGraphNeighborhood } from './wikilinks.js';
11
+ import { evaluateFilter } from './filter.js';
12
+ import { deserializeBinaryIndex } from './binary-format.js';
13
+ import { dequantizeFromInt8 } from './quantization.js';
10
14
  export { tokenize } from './lexical.js';
11
15
  const chunkTokens = new WeakMap();
12
16
  export const _stats = { corpusTokenizedChars: 0 };
@@ -33,9 +37,13 @@ export function keywordScores(chunks, query) {
33
37
  return s;
34
38
  });
35
39
  }
36
- export function rrf(rankings, k = 60) {
40
+ export function rrf(rankings, k = 60, weights) {
37
41
  const fused = new Map();
38
- for (const ranking of rankings) {
42
+ for (let rankingIdx = 0; rankingIdx < rankings.length; rankingIdx++) {
43
+ const ranking = rankings[rankingIdx];
44
+ const weight = weights ? (weights[rankingIdx] ?? 1) : 1;
45
+ if (weight <= 0)
46
+ continue;
39
47
  const sorted = [...ranking].sort((a, b) => b.score - a.score);
40
48
  let scoreRank = 0;
41
49
  sorted.forEach((item, rank) => {
@@ -43,7 +51,7 @@ export function rrf(rankings, k = 60) {
43
51
  return;
44
52
  if (rank > 0 && item.score < sorted[rank - 1].score)
45
53
  scoreRank = rank;
46
- fused.set(item.idx, (fused.get(item.idx) || 0) + 1 / (k + scoreRank + 1));
54
+ fused.set(item.idx, (fused.get(item.idx) || 0) + weight / (k + scoreRank + 1));
47
55
  });
48
56
  }
49
57
  return fused;
@@ -116,22 +124,51 @@ export class QueryEmbeddingCache {
116
124
  return this.cache.size;
117
125
  }
118
126
  }
127
+ export function getRuntimeGraph(runtime) {
128
+ if (runtime.graph && runtime.pageRank) {
129
+ return { graph: runtime.graph, pageRank: runtime.pageRank };
130
+ }
131
+ const docsMap = new Map();
132
+ for (const chunk of runtime.chunks) {
133
+ const existing = docsMap.get(chunk.file);
134
+ if (!existing) {
135
+ docsMap.set(chunk.file, {
136
+ file: chunk.file,
137
+ title: chunk.title,
138
+ meta: chunk.meta,
139
+ text: chunk.text,
140
+ });
141
+ }
142
+ else {
143
+ existing.text = (existing.text || '') + '\n' + chunk.text;
144
+ }
145
+ }
146
+ const docs = Array.from(docsMap.values());
147
+ const graph = buildRelationshipGraph(docs);
148
+ const pageRank = computePageRank(graph);
149
+ runtime.graph = graph;
150
+ runtime.pageRank = pageRank;
151
+ return { graph, pageRank };
152
+ }
119
153
  const runtimeIndexStates = new WeakMap();
120
154
  function snapshotRuntimeIndex(index, validation) {
121
155
  const dim = validation.dim || (index.chunks[0]?.vec?.length ?? 0);
122
156
  const count = index.chunks.length;
123
- const vectorsBuffer = dim > 0 ? new Float32Array(count * dim) : null;
157
+ const isZeroCopy = !!validation.rawVectorsBuffer;
158
+ const vectorsBuffer = validation.rawVectorsBuffer ?? (dim > 0 ? new Float32Array(count * dim) : null);
124
159
  const chunks = index.chunks.map((chunk, i) => {
125
160
  let vec;
126
161
  if (vectorsBuffer && dim > 0) {
127
162
  const offset = i * dim;
128
- if (chunk.vec instanceof Float32Array) {
129
- vectorsBuffer.set(chunk.vec, offset);
130
- }
131
- else {
132
- const arr = chunk.vec;
133
- for (let j = 0; j < dim; j++)
134
- vectorsBuffer[offset + j] = arr[j];
163
+ if (!isZeroCopy) {
164
+ if (chunk.vec instanceof Float32Array || chunk.vec instanceof Int8Array) {
165
+ vectorsBuffer.set(chunk.vec, offset);
166
+ }
167
+ else {
168
+ const arr = chunk.vec;
169
+ for (let j = 0; j < dim; j++)
170
+ vectorsBuffer[offset + j] = arr[j];
171
+ }
135
172
  }
136
173
  vec = vectorsBuffer.subarray(offset, offset + dim);
137
174
  }
@@ -224,7 +261,69 @@ function warnIfStale(index) {
224
261
  process.stderr.write(`warning: index is ${mins} min older than the newest change in ${index.db}; run \`mdss index\` to refresh.\n`);
225
262
  }
226
263
  export function loadIndex(indexDir) {
264
+ const vectorsBinPath = path.join(indexDir, 'vectors.bin');
227
265
  const vectorsPath = path.join(indexDir, 'vectors.json');
266
+ let useBinary = false;
267
+ if (fs.existsSync(vectorsBinPath)) {
268
+ if (!fs.existsSync(vectorsPath)) {
269
+ useBinary = true;
270
+ }
271
+ else {
272
+ try {
273
+ const binStat = fs.statSync(vectorsBinPath);
274
+ const jsonStat = fs.statSync(vectorsPath);
275
+ if (binStat.mtimeMs >= jsonStat.mtimeMs) {
276
+ useBinary = true;
277
+ }
278
+ }
279
+ catch {
280
+ useBinary = false;
281
+ }
282
+ }
283
+ }
284
+ if (useBinary) {
285
+ try {
286
+ const binBuffer = fs.readFileSync(vectorsBinPath);
287
+ const shaBinPath = path.join(indexDir, 'vectors.bin.sha256');
288
+ if (fs.existsSync(shaBinPath)) {
289
+ const expectedHash = fs.readFileSync(shaBinPath, 'utf8').trim().split(/\s+/)[0];
290
+ const actualHash = crypto.createHash('sha256').update(binBuffer).digest('hex');
291
+ if (expectedHash && actualHash !== expectedHash) {
292
+ throw new Error('vectors.bin integrity check failed (SHA-256 mismatch) — run `mdss index` to rebuild.');
293
+ }
294
+ }
295
+ const deserialized = deserializeBinaryIndex(binBuffer);
296
+ const validated = validateIndexEnvelope(deserialized, vectorsBinPath, { encoding: 'loaded', validateVectors: true });
297
+ const index = validated.index;
298
+ const { model, dim: validatedDim } = validated;
299
+ const expectedDim = validatedDim ?? deserialized.dim;
300
+ warnIfStale(index);
301
+ const ivfPath = path.join(indexDir, 'ivf.json');
302
+ let ivf;
303
+ if (fs.existsSync(ivfPath)) {
304
+ try {
305
+ const raw = JSON.parse(fs.readFileSync(ivfPath, 'utf8'));
306
+ ivf = deserializeIVF(raw);
307
+ }
308
+ catch { }
309
+ }
310
+ runtimeIndexStates.set(index, snapshotRuntimeIndex(index, {
311
+ schema: validated.schema,
312
+ dim: expectedDim,
313
+ model,
314
+ ivf,
315
+ rawVectorsBuffer: deserialized.rawVectorsBuffer,
316
+ }));
317
+ return { index, model };
318
+ }
319
+ catch (e) {
320
+ if (e.message?.includes('integrity check failed'))
321
+ throw e;
322
+ if (!fs.existsSync(vectorsPath))
323
+ throw e;
324
+ // If vectors.bin was corrupt or unreadable, fall through to vectors.json fallback
325
+ }
326
+ }
228
327
  if (!fs.existsSync(vectorsPath)) {
229
328
  throw new Error(`No index at ${vectorsPath}. Run \`mdss index\` first.`);
230
329
  }
@@ -297,7 +396,8 @@ export function loadIndex(indexDir) {
297
396
  }
298
397
  export const MAX_QUERY_LENGTH = 2048;
299
398
  export async function searchIndex(opts) {
300
- const { loaded, cacheDir, query, k = 6, semanticOnly = false, offline = false, path: pathFilter, since, rerank = false, rerankPool, tag, project, type, status, canonicalOnly, custom, } = opts;
399
+ const { loaded, cacheDir, query, k = 6, semanticOnly = false, offline = false, path: pathFilter, since, rerank = false, rerankPool, tag, project, type, status, canonicalOnly, custom, filter: filterExpr, } = opts;
400
+ const graphBoost = typeof opts.graphBoost === 'number' && Number.isFinite(opts.graphBoost) ? opts.graphBoost : 0;
301
401
  if (typeof query === 'string' && query.length > MAX_QUERY_LENGTH) {
302
402
  throw new Error(`query exceeds maximum length of ${MAX_QUERY_LENGTH} characters`);
303
403
  }
@@ -319,7 +419,8 @@ export async function searchIndex(opts) {
319
419
  type !== undefined ||
320
420
  status !== undefined ||
321
421
  canonicalOnly !== undefined ||
322
- custom !== undefined;
422
+ custom !== undefined ||
423
+ filterExpr !== undefined;
323
424
  if (pathRes.length > 0 || sinceMs !== undefined || hasMetaFilter) {
324
425
  if (sinceMs !== undefined && !db) {
325
426
  throw new Error('--since requires an index that knows its --db (index.db missing). Re-run `mdss index`.');
@@ -342,6 +443,10 @@ export async function searchIndex(opts) {
342
443
  if (m < sinceMs)
343
444
  return false;
344
445
  }
446
+ if (filterExpr !== undefined) {
447
+ if (!evaluateFilter(filterExpr, chunk.meta, { file: chunk.file, title: chunk.title }))
448
+ return false;
449
+ }
345
450
  if (tag !== undefined) {
346
451
  const reqTags = (Array.isArray(tag) ? tag : [tag]).map((t) => String(t).toLowerCase().replace(/^#/, ''));
347
452
  const chunkTags = chunk.meta?.tags || [];
@@ -390,7 +495,7 @@ export async function searchIndex(opts) {
390
495
  const useAnn = opts.ann ?? (runtime.ivf !== undefined && candidates.length >= ANN_THRESHOLD);
391
496
  if (useAnn && candidates.length >= ANN_THRESHOLD) {
392
497
  if (!runtime.ivf) {
393
- const rawVecs = chunks.map((c) => c.vec);
498
+ const rawVecs = chunks.map((c) => (c.vec instanceof Float32Array ? c.vec : dequantizeFromInt8(c.vec)));
394
499
  runtime.ivf = trainIVF(rawVecs);
395
500
  }
396
501
  const qVecF32 = qVec instanceof Float32Array ? qVec : Float32Array.from(qVec);
@@ -409,12 +514,45 @@ export async function searchIndex(opts) {
409
514
  }
410
515
  const cosByIdx = new Map(semantic.map((s) => [s.idx, s.score]));
411
516
  const pool = rerank ? rerankPool || Math.max(20, k * 3) : k;
517
+ let graphRanking = [];
518
+ let graphMap;
519
+ if (graphBoost > 0) {
520
+ const { graph, pageRank } = getRuntimeGraph(runtime);
521
+ const seedFilesMap = new Map();
522
+ for (const s of semantic) {
523
+ if (s.score > 0) {
524
+ const file = chunks[s.idx].file;
525
+ const curr = seedFilesMap.get(file) || 0;
526
+ if (s.score > curr)
527
+ seedFilesMap.set(file, s.score);
528
+ }
529
+ }
530
+ const seedFiles = Array.from(seedFilesMap.entries()).map(([file, score]) => ({ file, score }));
531
+ const propagationScores = expandGraphNeighborhood(graph, seedFiles, { maxDepth: 2, decay: 0.5 });
532
+ graphMap = new Map();
533
+ graphRanking = candidates.map(({ chunk, idx }) => {
534
+ const pr = pageRank.get(chunk.file) || 0;
535
+ const prop = propagationScores.get(chunk.file) || 0;
536
+ const totalGraph = pr + prop;
537
+ graphMap.set(idx, totalGraph);
538
+ return { idx, score: totalGraph };
539
+ });
540
+ }
412
541
  let ranked;
413
542
  if (semanticOnly) {
414
- ranked = [...semantic]
415
- .sort((a, b) => b.score - a.score)
416
- .slice(0, pool)
417
- .map((s) => ({ idx: s.idx, fscore: s.score, cos: s.score }));
543
+ if (graphBoost > 0) {
544
+ const fused = rrf([semantic, graphRanking], 60, [1, graphBoost]);
545
+ ranked = [...fused.entries()]
546
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
547
+ .sort((a, b) => b.fscore - a.fscore)
548
+ .slice(0, pool);
549
+ }
550
+ else {
551
+ ranked = [...semantic]
552
+ .sort((a, b) => b.score - a.score)
553
+ .slice(0, pool)
554
+ .map((s) => ({ idx: s.idx, fscore: s.score, cos: s.score }));
555
+ }
418
556
  }
419
557
  else {
420
558
  const kw = lexicalState.kind === 'persisted-bm25'
@@ -427,11 +565,20 @@ export async function searchIndex(opts) {
427
565
  idx: candidates[position].idx,
428
566
  score,
429
567
  }));
430
- const fused = rrf([semantic, kw, fuzzy]);
431
- ranked = [...fused.entries()]
432
- .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
433
- .sort((a, b) => b.fscore - a.fscore)
434
- .slice(0, pool);
568
+ if (graphBoost > 0) {
569
+ const fused = rrf([semantic, kw, fuzzy, graphRanking], 60, [1, 1, 1, graphBoost]);
570
+ ranked = [...fused.entries()]
571
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
572
+ .sort((a, b) => b.fscore - a.fscore)
573
+ .slice(0, pool);
574
+ }
575
+ else {
576
+ const fused = rrf([semantic, kw, fuzzy]);
577
+ ranked = [...fused.entries()]
578
+ .map(([idx, fscore]) => ({ idx, fscore, cos: cosByIdx.get(idx) }))
579
+ .sort((a, b) => b.fscore - a.fscore)
580
+ .slice(0, pool);
581
+ }
435
582
  }
436
583
  if (rerank && ranked.length > 0) {
437
584
  const texts = ranked.map((r) => chunks[r.idx].text);
@@ -458,6 +605,8 @@ export async function searchIndex(opts) {
458
605
  const matches = lexicalState.kind === 'persisted-bm25'
459
606
  ? matchingTerms(lexicalState.lexical, queryTerms, r.idx)
460
607
  : queryTerms.filter((term) => tokenSet(c).has(term));
608
+ const gScore = graphMap?.get(r.idx);
609
+ const prScore = runtime.pageRank?.get(c.file);
461
610
  return {
462
611
  file: c.file,
463
612
  title: c.title,
@@ -467,12 +616,15 @@ export async function searchIndex(opts) {
467
616
  matches,
468
617
  snippet: c.text.replace(/\s+/g, ' ').slice(0, 220),
469
618
  ...(hasMetaFields(c.meta) ? { meta: c.meta } : {}),
619
+ ...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
470
620
  ...(explain
471
621
  ? {
472
622
  explain: {
473
623
  cosine: +r.cos.toFixed(4),
474
624
  rrfScore: +r.fscore.toFixed(4),
475
625
  bm25Weights: { title: 3.0, aliases: 3.0, headingPath: 1.8, body: 1.0 },
626
+ ...(gScore !== undefined ? { graphScore: +gScore.toFixed(4) } : {}),
627
+ ...(prScore !== undefined ? { pageRank: +prScore.toFixed(4) } : {}),
476
628
  },
477
629
  }
478
630
  : {}),
package/dist/serve.js 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 } from './core.js';
13
+ import { createFileWatcher } from './watcher.js';
13
14
  const DEFAULT_PORT = 8747;
14
15
  const DEFAULT_HOST = '127.0.0.1';
15
16
  const WATCH_INTERVAL_MS = 3000;
@@ -39,13 +40,19 @@ export async function createServe(opts) {
39
40
  apiKey,
40
41
  healthPublic,
41
42
  };
43
+ const sockets = new Set();
42
44
  const server = http.createServer((req, res) => {
43
45
  handleRequest(req, res, state).catch((e) => {
44
46
  json(res, 500, { error: e.message });
45
47
  });
46
48
  });
49
+ server.on('connection', (socket) => {
50
+ sockets.add(socket);
51
+ socket.on('close', () => sockets.delete(socket));
52
+ });
47
53
  let stopped = false;
48
54
  let timer = null;
55
+ let resolveTimer = null;
49
56
  const reload = async () => {
50
57
  if (!db)
51
58
  return false;
@@ -140,14 +147,21 @@ export async function createServe(opts) {
140
147
  }
141
148
  return out;
142
149
  };
150
+ let lastFingerprint = '';
143
151
  const watchLoop = async () => {
144
152
  let indexedHashes = readIndexedHashes();
145
- let lastFingerprint = treeContentFingerprint(scanTree());
153
+ lastFingerprint = treeContentFingerprint(scanTree());
146
154
  let lastChangeAt = 0;
147
155
  let pending = false;
148
156
  while (!stopped) {
149
157
  await new Promise((r) => {
150
- timer = setTimeout(r, watchInterval);
158
+ resolveTimer = r;
159
+ timer = setTimeout(() => {
160
+ timer = null;
161
+ resolveTimer = null;
162
+ r();
163
+ }, watchInterval);
164
+ timer.unref?.();
151
165
  });
152
166
  if (stopped)
153
167
  return;
@@ -182,15 +196,40 @@ export async function createServe(opts) {
182
196
  }
183
197
  }
184
198
  };
199
+ let nativeWatcher = null;
185
200
  if (watch) {
186
201
  if (!db)
187
202
  throw new Error('serve --watch requires --db');
203
+ nativeWatcher = createFileWatcher(db, () => {
204
+ // Prompt watchLoop settle trigger on native event
205
+ lastFingerprint = '';
206
+ }, {
207
+ debounceMs: watchDelay,
208
+ ignore,
209
+ log,
210
+ });
188
211
  watchLoop();
189
212
  }
190
213
  const close = async () => {
191
214
  stopped = true;
192
- if (timer)
215
+ if (timer) {
193
216
  clearTimeout(timer);
217
+ timer = null;
218
+ }
219
+ if (resolveTimer) {
220
+ resolveTimer();
221
+ resolveTimer = null;
222
+ }
223
+ if (nativeWatcher)
224
+ nativeWatcher.close();
225
+ server.closeAllConnections?.();
226
+ for (const socket of sockets) {
227
+ try {
228
+ socket.destroy();
229
+ }
230
+ catch { }
231
+ }
232
+ sockets.clear();
194
233
  await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
195
234
  };
196
235
  return { server, state, close };
@@ -282,6 +321,8 @@ async function handleRequest(req, res, state) {
282
321
  maxPerFile: payload.maxPerFile || payload.maxPerDoc,
283
322
  ann: payload.ann,
284
323
  nprobe: payload.nprobe,
324
+ graphBoost: payload.graphBoost,
325
+ filter: payload.filter,
285
326
  });
286
327
  json(res, 200, { query, k, count: results.length, results });
287
328
  }
@@ -0,0 +1,16 @@
1
+ export interface NoteAnalysis {
2
+ tags: string[];
3
+ summary: string;
4
+ }
5
+ /**
6
+ * Extracts salient topical tags from Markdown text using term frequency and position weighting.
7
+ */
8
+ export declare function extractKeyphraseTags(text: string, maxTags?: number): string[];
9
+ /**
10
+ * Generates a concise extractive summary (1-2 sentences) representing the salient points of a note.
11
+ */
12
+ export declare function summarizePassage(text: string, maxSentences?: number): string;
13
+ /**
14
+ * Analyzes a Markdown note to produce automated semantic tags and summary.
15
+ */
16
+ export declare function analyzeNote(text: string): NoteAnalysis;
@@ -0,0 +1,82 @@
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
+ * Extracts salient topical tags from Markdown text using term frequency and position weighting.
10
+ */
11
+ export function extractKeyphraseTags(text, maxTags = 5) {
12
+ if (!text || !text.trim())
13
+ return [];
14
+ const lines = text.split('\n');
15
+ const termScores = new Map();
16
+ lines.forEach((line, lineIdx) => {
17
+ const isHeading = /^#{1,6}\s+/.test(line);
18
+ const weight = isHeading ? 3.0 : lineIdx < 3 ? 1.5 : 1.0;
19
+ const tokens = tokenize(line);
20
+ for (const tok of tokens) {
21
+ if (tok.length < 3)
22
+ continue;
23
+ // Skip purely numeric tokens
24
+ if (/^\d+$/.test(tok))
25
+ continue;
26
+ const current = termScores.get(tok) || 0;
27
+ termScores.set(tok, current + weight);
28
+ }
29
+ });
30
+ const sorted = Array.from(termScores.entries())
31
+ .sort((a, b) => b[1] - a[1])
32
+ .slice(0, maxTags)
33
+ .map(([term]) => term);
34
+ return sorted;
35
+ }
36
+ /**
37
+ * Generates a concise extractive summary (1-2 sentences) representing the salient points of a note.
38
+ */
39
+ export function summarizePassage(text, maxSentences = 2) {
40
+ if (!text || !text.trim())
41
+ return '';
42
+ // Clean Markdown formatting
43
+ const clean = text
44
+ .replace(/^#{1,6}\s+.*$/gm, '')
45
+ .replace(/```[\s\S]*?```/g, '')
46
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
47
+ .replace(/[*_`~]/g, '')
48
+ .trim();
49
+ const sentences = clean
50
+ .split(/(?<=[.?!])\s+|\n\n+/)
51
+ .map((s) => s.trim())
52
+ .filter((s) => s.length >= 20 && s.length <= 300);
53
+ if (sentences.length <= maxSentences) {
54
+ return sentences.join(' ');
55
+ }
56
+ const salientTags = new Set(extractKeyphraseTags(clean, 8));
57
+ const scored = sentences.map((sent, index) => {
58
+ const tokens = tokenize(sent);
59
+ let tagMatches = 0;
60
+ for (const t of tokens) {
61
+ if (salientTags.has(t))
62
+ tagMatches++;
63
+ }
64
+ // Favor earlier sentences and sentences containing salient keywords
65
+ const posScore = 1.0 / (index + 1);
66
+ const salienceScore = tagMatches / (tokens.length + 1);
67
+ const score = posScore * 0.4 + salienceScore * 0.6;
68
+ return { sent, score, index };
69
+ });
70
+ scored.sort((a, b) => b.score - a.score);
71
+ const selected = scored.slice(0, maxSentences).sort((a, b) => a.index - b.index);
72
+ return selected.map((s) => s.sent).join(' ');
73
+ }
74
+ /**
75
+ * Analyzes a Markdown note to produce automated semantic tags and summary.
76
+ */
77
+ export function analyzeNote(text) {
78
+ return {
79
+ tags: extractKeyphraseTags(text),
80
+ summary: summarizePassage(text),
81
+ };
82
+ }
package/dist/tui.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface TuiOptions {
10
10
  path?: string | string[];
11
11
  rerank?: boolean;
12
12
  rerankPool?: number;
13
+ rag?: boolean;
13
14
  embedFn?: any;
14
15
  rerankFn?: any;
15
16
  debounceMs?: number;