md-semantic-search 0.3.0 → 0.5.0

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 (59) hide show
  1. package/README.md +438 -20
  2. package/RESEARCH.md +117 -37
  3. package/bin/cli.mjs +931 -36
  4. package/dist/collapse.d.ts +6 -0
  5. package/dist/collapse.js +20 -0
  6. package/dist/core.d.ts +84 -0
  7. package/dist/core.js +479 -0
  8. package/dist/frontmatter.d.ts +20 -0
  9. package/dist/frontmatter.js +150 -0
  10. package/dist/index-format.d.ts +23 -0
  11. package/dist/index-format.js +125 -0
  12. package/dist/index.d.ts +29 -0
  13. package/dist/index.js +29 -0
  14. package/dist/indexer.d.ts +70 -0
  15. package/dist/indexer.js +452 -0
  16. package/dist/ivf.d.ts +33 -0
  17. package/dist/ivf.js +144 -0
  18. package/dist/lexical.d.ts +44 -0
  19. package/dist/lexical.js +226 -0
  20. package/dist/markdown-parser.d.ts +27 -0
  21. package/dist/markdown-parser.js +252 -0
  22. package/dist/mcp.d.ts +111 -0
  23. package/dist/mcp.js +216 -0
  24. package/dist/metrics.d.ts +43 -0
  25. package/dist/metrics.js +97 -0
  26. package/dist/models.d.ts +38 -0
  27. package/dist/models.js +181 -0
  28. package/dist/rerank.d.ts +10 -0
  29. package/dist/rerank.js +41 -0
  30. package/dist/search.d.ts +135 -0
  31. package/dist/search.js +492 -0
  32. package/dist/serve.d.ts +49 -0
  33. package/dist/serve.js +321 -0
  34. package/dist/tui.d.ts +17 -0
  35. package/dist/tui.js +196 -0
  36. package/dist/wikilinks.d.ts +47 -0
  37. package/dist/wikilinks.js +171 -0
  38. package/package.json +25 -2
  39. package/src/collapse.ts +26 -0
  40. package/src/core.ts +546 -0
  41. package/src/frontmatter.ts +190 -0
  42. package/src/index-format.ts +151 -0
  43. package/src/index.ts +55 -0
  44. package/src/indexer.ts +585 -0
  45. package/src/ivf.ts +190 -0
  46. package/src/lexical.ts +257 -0
  47. package/src/markdown-parser.ts +303 -0
  48. package/src/mcp.ts +243 -0
  49. package/src/metrics.ts +135 -0
  50. package/src/models.ts +219 -0
  51. package/src/rerank.ts +54 -0
  52. package/src/search.ts +628 -0
  53. package/src/serve.ts +372 -0
  54. package/src/tui.ts +227 -0
  55. package/src/wikilinks.ts +244 -0
  56. package/src/core.mjs +0 -171
  57. package/src/indexer.mjs +0 -197
  58. package/src/models.mjs +0 -67
  59. package/src/search.mjs +0 -107
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Typed YAML Frontmatter parser and metadata normalizer (issue #58).
3
+ * Parses YAML frontmatter into a validated document metadata object without
4
+ * unsafe executable tags. Supports scalars, inline/block lists, quotes, booleans,
5
+ * dates, tags normalization, and canonical document identity.
6
+ */
7
+ export function parseFrontmatter(rawYaml) {
8
+ const metadata = {
9
+ aliases: [],
10
+ tags: [],
11
+ custom: {},
12
+ };
13
+ if (!rawYaml || typeof rawYaml !== 'string') {
14
+ return metadata;
15
+ }
16
+ const lines = rawYaml.split(/\r?\n/);
17
+ let currentKey = null;
18
+ let currentBlockList = null;
19
+ for (let i = 0; i < lines.length; i++) {
20
+ const line = lines[i];
21
+ const trimmed = line.trim();
22
+ if (!trimmed || trimmed.startsWith('#')) {
23
+ continue;
24
+ }
25
+ const blockListMatch = line.match(/^(\s+)-\s+(.+)$/);
26
+ if (blockListMatch && currentKey && currentBlockList) {
27
+ const val = parseYAMLValue(blockListMatch[2]);
28
+ if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
29
+ currentBlockList.push(String(val));
30
+ }
31
+ continue;
32
+ }
33
+ const kvMatch = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
34
+ if (kvMatch) {
35
+ if (currentKey && currentBlockList) {
36
+ setMetadataValue(metadata, currentKey, currentBlockList);
37
+ currentKey = null;
38
+ currentBlockList = null;
39
+ }
40
+ const key = kvMatch[1].trim();
41
+ const rawVal = kvMatch[2].trim();
42
+ if (rawVal === '') {
43
+ currentKey = key;
44
+ currentBlockList = [];
45
+ }
46
+ else {
47
+ const val = parseYAMLValue(rawVal);
48
+ setMetadataValue(metadata, key, val);
49
+ }
50
+ }
51
+ }
52
+ if (currentKey && currentBlockList) {
53
+ setMetadataValue(metadata, currentKey, currentBlockList);
54
+ }
55
+ return metadata;
56
+ }
57
+ function parseYAMLValue(raw) {
58
+ const trimmed = raw.trim();
59
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
60
+ const inner = trimmed.slice(1, -1).trim();
61
+ if (!inner)
62
+ return [];
63
+ return inner
64
+ .split(',')
65
+ .map((item) => parseYAMLValue(item))
66
+ .map((item) => (Array.isArray(item) ? item.join(' ') : String(item).trim()))
67
+ .filter((item) => item.length > 0);
68
+ }
69
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
70
+ return trimmed.slice(1, -1).trim();
71
+ }
72
+ if (/^true$/i.test(trimmed))
73
+ return true;
74
+ if (/^false$/i.test(trimmed))
75
+ return false;
76
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
77
+ const num = Number(trimmed);
78
+ if (!Number.isNaN(num))
79
+ return num;
80
+ }
81
+ return trimmed;
82
+ }
83
+ function setMetadataValue(meta, key, val) {
84
+ const normKey = key.toLowerCase().replace(/[-_]/g, '');
85
+ if (normKey === 'title' && typeof val === 'string') {
86
+ meta.title = val;
87
+ return;
88
+ }
89
+ if (normKey === 'alias' || normKey === 'aliases') {
90
+ const items = Array.isArray(val) ? val : [String(val)];
91
+ for (const item of items) {
92
+ const cleaned = item.trim().replace(/^["']|["']$/g, '');
93
+ if (cleaned && !meta.aliases.includes(cleaned)) {
94
+ meta.aliases.push(cleaned);
95
+ }
96
+ }
97
+ return;
98
+ }
99
+ if (normKey === 'tag' || normKey === 'tags') {
100
+ let items;
101
+ if (Array.isArray(val)) {
102
+ items = val;
103
+ }
104
+ else if (typeof val === 'string') {
105
+ items = val.includes(',') ? val.split(',') : [val];
106
+ }
107
+ else {
108
+ items = [String(val)];
109
+ }
110
+ for (const item of items) {
111
+ const cleaned = String(item)
112
+ .trim()
113
+ .replace(/^#/, '')
114
+ .toLowerCase();
115
+ if (cleaned && !meta.tags.includes(cleaned)) {
116
+ meta.tags.push(cleaned);
117
+ }
118
+ }
119
+ return;
120
+ }
121
+ if (normKey === 'project' && (typeof val === 'string' || typeof val === 'number')) {
122
+ meta.project = String(val);
123
+ return;
124
+ }
125
+ if (normKey === 'type' && typeof val === 'string') {
126
+ meta.type = val;
127
+ return;
128
+ }
129
+ if (normKey === 'status' && typeof val === 'string') {
130
+ meta.status = val;
131
+ return;
132
+ }
133
+ if (normKey === 'canonical') {
134
+ meta.canonical = Boolean(val);
135
+ return;
136
+ }
137
+ if ((normKey === 'canonicalref' || normKey === 'canonical_ref') && typeof val === 'string') {
138
+ meta.canonicalRef = val.replace(/^\[\[(.*)\]\]$/, '$1').trim();
139
+ return;
140
+ }
141
+ if (normKey === 'created') {
142
+ meta.created = String(val);
143
+ return;
144
+ }
145
+ if (normKey === 'updated') {
146
+ meta.updated = String(val);
147
+ return;
148
+ }
149
+ meta.custom[key] = val;
150
+ }
@@ -0,0 +1,23 @@
1
+ import { ModelAdapter } from './models.js';
2
+ export declare function inspectIndexSchema(value: unknown, location: string): {
3
+ index: Record<string, unknown>;
4
+ schema: number;
5
+ };
6
+ export declare function validateNumericVector(value: unknown, expectedDim: number | undefined, where: string): number;
7
+ export declare function validateCurrentChunk(chunk: unknown, position: number, options: {
8
+ dim: number | undefined;
9
+ encoding: 'stored' | 'loaded' | 'none';
10
+ allowMissingVector?: boolean;
11
+ }): void;
12
+ export interface ValidatedIndexEnvelope {
13
+ index: Record<string, any>;
14
+ schema: number;
15
+ model: ModelAdapter;
16
+ dim: number | undefined;
17
+ }
18
+ export declare function validateIndexEnvelope(value: unknown, location: string, options?: {
19
+ encoding?: 'stored' | 'loaded';
20
+ validateVectors?: boolean;
21
+ allowMissingVectors?: boolean;
22
+ validateLexical?: boolean;
23
+ }): ValidatedIndexEnvelope;
@@ -0,0 +1,125 @@
1
+ import { decodeVec, parseSchemaVersion, resolveIndexDimension, SCHEMA_VERSION } from './core.js';
2
+ import { validateLexicalIndex } from './lexical.js';
3
+ import { resolveModel } from './models.js';
4
+ const REBUILD = ' — run `mdss index` to rebuild';
5
+ export function inspectIndexSchema(value, location) {
6
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
7
+ throw new Error(`${location} root must be an object${REBUILD}`);
8
+ }
9
+ const index = value;
10
+ return { index, schema: parseSchemaVersion(index.schemaVersion) };
11
+ }
12
+ export function validateNumericVector(value, expectedDim, where) {
13
+ if (!Array.isArray(value) && !(value instanceof Float32Array)) {
14
+ throw new Error(`${where}: missing vector${REBUILD}`);
15
+ }
16
+ const vec = value;
17
+ if (vec.length === 0)
18
+ throw new Error(`${where}: vector must not be empty${REBUILD}`);
19
+ if (expectedDim !== undefined && vec.length !== expectedDim) {
20
+ throw new Error(`${where}: vector has ${vec.length} dims, expected ${expectedDim}${REBUILD}`);
21
+ }
22
+ for (let position = 0; position < vec.length; position++) {
23
+ if (Array.isArray(value) && !Object.hasOwn(value, position)) {
24
+ throw new Error(`${where}: missing vector component at ${position}${REBUILD}`);
25
+ }
26
+ if (typeof vec[position] !== 'number' || !Number.isFinite(vec[position])) {
27
+ throw new Error(`${where}: non-finite vector value${REBUILD}`);
28
+ }
29
+ }
30
+ return vec.length;
31
+ }
32
+ export function validateCurrentChunk(chunk, position, options) {
33
+ const prefix = `chunk ${position}`;
34
+ if (chunk === null || typeof chunk !== 'object' || Array.isArray(chunk)) {
35
+ throw new Error(`${prefix} must be an object${REBUILD}`);
36
+ }
37
+ const value = chunk;
38
+ for (const field of ['file', 'title', 'heading', 'text', 'chunkHash']) {
39
+ if (typeof value[field] !== 'string') {
40
+ throw new Error(`${prefix} ${field} must be a string${REBUILD}`);
41
+ }
42
+ }
43
+ if (!Array.isArray(value.headingPath) || value.headingPath.length === 0) {
44
+ throw new Error(`${prefix} headingPath must contain nonempty strings${REBUILD}`);
45
+ }
46
+ for (let pathPosition = 0; pathPosition < value.headingPath.length; pathPosition++) {
47
+ if (!Object.hasOwn(value.headingPath, pathPosition) ||
48
+ typeof value.headingPath[pathPosition] !== 'string' ||
49
+ value.headingPath[pathPosition].trim().length === 0) {
50
+ throw new Error(`${prefix} headingPath must contain nonempty strings${REBUILD}`);
51
+ }
52
+ }
53
+ if (value.headingPath[value.headingPath.length - 1] !== value.heading) {
54
+ throw new Error(`${prefix} headingPath leaf must equal heading${REBUILD}`);
55
+ }
56
+ if (options.encoding === 'none')
57
+ return;
58
+ const vectorWhere = `${prefix}; chunk ${value.file}` + (value.heading ? ` › ${value.heading}` : '');
59
+ if (value.vec === undefined && options.allowMissingVector)
60
+ return;
61
+ if (options.encoding === 'stored') {
62
+ if (typeof value.vec !== 'string') {
63
+ throw new Error(`${prefix} vec must be canonical base64${REBUILD}`);
64
+ }
65
+ try {
66
+ decodeVec(value.vec, options.dim);
67
+ }
68
+ catch (error) {
69
+ throw new Error(`${vectorWhere}: ${error.message}`, { cause: error });
70
+ }
71
+ return;
72
+ }
73
+ validateNumericVector(value.vec, options.dim, vectorWhere);
74
+ }
75
+ export function validateIndexEnvelope(value, location, options = {}) {
76
+ const { index, schema } = inspectIndexSchema(value, location);
77
+ if (schema > SCHEMA_VERSION) {
78
+ throw new Error(`${location} uses schema v${schema}, newer than this mdss supports; supports up to v${SCHEMA_VERSION} — upgrade md-semantic-search`);
79
+ }
80
+ if (!Array.isArray(index.chunks)) {
81
+ throw new Error(`${location} chunks must be an array${REBUILD}`);
82
+ }
83
+ let modelName = index.model ?? index.modelAlias;
84
+ if (schema >= 3) {
85
+ if (typeof index.model !== 'string' || index.model.trim().length === 0) {
86
+ throw new Error(`schema v3 model must be a nonempty string${REBUILD}`);
87
+ }
88
+ modelName = index.model;
89
+ }
90
+ else if (modelName !== undefined && modelName !== null && typeof modelName !== 'string') {
91
+ throw new Error(`${location} model metadata must be a string${REBUILD}`);
92
+ }
93
+ const model = resolveModel(modelName);
94
+ const dim = resolveIndexDimension(index.dim, model.dim);
95
+ if (schema < 3) {
96
+ return { index, schema, model, dim };
97
+ }
98
+ if (index.format !== 'binary-v1') {
99
+ throw new Error(`schema v3 format must be binary-v1${REBUILD}`);
100
+ }
101
+ if (typeof index.chunkCount !== 'number' ||
102
+ !Number.isSafeInteger(index.chunkCount) ||
103
+ index.chunkCount < 0 ||
104
+ index.chunkCount !== index.chunks.length) {
105
+ throw new Error(`schema v3 chunkCount must equal chunks.length${REBUILD}`);
106
+ }
107
+ if (index.chunks.length > 0 && dim === undefined) {
108
+ throw new Error(`nonempty schema v3 custom index requires explicit dim${REBUILD}`);
109
+ }
110
+ const encoding = options.validateVectors === false ? 'none' : (options.encoding ?? 'stored');
111
+ for (let position = 0; position < index.chunks.length; position++) {
112
+ validateCurrentChunk(index.chunks[position], position, {
113
+ dim,
114
+ encoding,
115
+ allowMissingVector: options.allowMissingVectors,
116
+ });
117
+ }
118
+ if (options.validateLexical !== false) {
119
+ const lexicalError = validateLexicalIndex(index.lexical, index.chunks.length);
120
+ if (lexicalError) {
121
+ throw new Error(`schema v3 lexical index is invalid (${lexicalError})${REBUILD}`);
122
+ }
123
+ }
124
+ return { index, schema, model, dim };
125
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Public library API for md-semantic-search.
3
+ *
4
+ * Import from a consumer project:
5
+ *
6
+ * import { buildIndex, search, loadIndex, searchIndex, resolveModel, MODELS } from 'md-semantic-search';
7
+ *
8
+ * One-shot usage (parses the index on every call):
9
+ *
10
+ * await buildIndex({ db: './docs', indexDir: './.mdss', cacheDir: '~/.cache/mdss' });
11
+ * const hits = await search({ indexDir: './.mdss', cacheDir: '~/.cache/mdss', query: 'rotate api token' });
12
+ *
13
+ * Repeated queries in one process (parse + model cached, issue #2):
14
+ *
15
+ * const loaded = loadIndex('./.mdss');
16
+ * const hitsA = await searchIndex({ loaded, cacheDir: '~/.cache/mdss', query: 'failover runbook' });
17
+ * const hitsB = await searchIndex({ loaded, cacheDir: '~/.cache/mdss', query: 'db backup' });
18
+ *
19
+ * All functions are model- and path-agnostic: they take explicit paths, so the
20
+ * same API works on any folder of markdown anywhere on disk.
21
+ */
22
+ export { buildIndex, chunkHash } from './indexer.js';
23
+ export { search, searchIndex, loadIndex, tokenize, keywordScores, rrf, QueryEmbeddingCache, } from './search.js';
24
+ export { resolveModel, MODELS, DEFAULT_MODEL, normalizeAdapter, embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint, } from './models.js';
25
+ export { getReranker, rerankScores, RERANK_MODEL, } from './rerank.js';
26
+ export { embed, getExtractor, cosine, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, } from './core.js';
27
+ export { MCP_TOOLS, handleMcpRequest, startMcpServer, } from './mcp.js';
28
+ export { trainIVF, searchIVFCandidates, serializeIVF, deserializeIVF, ANN_THRESHOLD, DEFAULT_NPROBE, } from './ivf.js';
29
+ export { runTui } from './tui.js';
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Public library API for md-semantic-search.
3
+ *
4
+ * Import from a consumer project:
5
+ *
6
+ * import { buildIndex, search, loadIndex, searchIndex, resolveModel, MODELS } from 'md-semantic-search';
7
+ *
8
+ * One-shot usage (parses the index on every call):
9
+ *
10
+ * await buildIndex({ db: './docs', indexDir: './.mdss', cacheDir: '~/.cache/mdss' });
11
+ * const hits = await search({ indexDir: './.mdss', cacheDir: '~/.cache/mdss', query: 'rotate api token' });
12
+ *
13
+ * Repeated queries in one process (parse + model cached, issue #2):
14
+ *
15
+ * const loaded = loadIndex('./.mdss');
16
+ * const hitsA = await searchIndex({ loaded, cacheDir: '~/.cache/mdss', query: 'failover runbook' });
17
+ * const hitsB = await searchIndex({ loaded, cacheDir: '~/.cache/mdss', query: 'db backup' });
18
+ *
19
+ * All functions are model- and path-agnostic: they take explicit paths, so the
20
+ * same API works on any folder of markdown anywhere on disk.
21
+ */
22
+ export { buildIndex, chunkHash } from './indexer.js';
23
+ export { search, searchIndex, loadIndex, tokenize, keywordScores, rrf, QueryEmbeddingCache, } from './search.js';
24
+ export { resolveModel, MODELS, DEFAULT_MODEL, normalizeAdapter, embeddingAdapterFingerprint, legacyEmbeddingAdapterFingerprint, } from './models.js';
25
+ export { getReranker, rerankScores, RERANK_MODEL, } from './rerank.js';
26
+ export { embed, getExtractor, cosine, walkMarkdown, parseFile, chunkMarkdown, splitFrontmatter, extractTitle, globToRegExp, encodeVec, decodeVec, isBinaryIndex, } from './core.js';
27
+ export { MCP_TOOLS, handleMcpRequest, startMcpServer, } from './mcp.js';
28
+ export { trainIVF, searchIVFCandidates, serializeIVF, deserializeIVF, ANN_THRESHOLD, DEFAULT_NPROBE, } from './ivf.js';
29
+ export { runTui } from './tui.js';
@@ -0,0 +1,70 @@
1
+ import { LexicalIndex } from './lexical.js';
2
+ export interface IndexChunk {
3
+ file: string;
4
+ title: string;
5
+ heading: string;
6
+ headingPath: string[];
7
+ text: string;
8
+ vec?: number[] | Float32Array | string;
9
+ chunkHash?: string;
10
+ }
11
+ export interface PersistedIndex {
12
+ schemaVersion?: number;
13
+ format?: string;
14
+ model?: string | null;
15
+ modelAlias?: string;
16
+ adapterFingerprint?: string;
17
+ dim?: number;
18
+ db?: string;
19
+ built?: string;
20
+ complete?: boolean;
21
+ chunkCount?: number;
22
+ hashes?: Record<string, string>;
23
+ lexical?: LexicalIndex;
24
+ chunks: IndexChunk[];
25
+ }
26
+ export declare function canonicalPassage(chunk: {
27
+ title: string;
28
+ heading: string;
29
+ headingPath?: string[];
30
+ text: string;
31
+ }): string;
32
+ export declare function chunkHash(model: {
33
+ id: string;
34
+ revision?: string;
35
+ passagePrefix?: string;
36
+ pooling?: string;
37
+ }, chunk: {
38
+ title: string;
39
+ heading: string;
40
+ headingPath?: string[];
41
+ text: string;
42
+ }): string;
43
+ export interface BuildIndexOptions {
44
+ db: string;
45
+ indexDir: string;
46
+ cacheDir: string;
47
+ modelName: string;
48
+ ignore?: string[];
49
+ offline?: boolean;
50
+ log?: (s: string) => void;
51
+ embedFn?: any;
52
+ _lockHeld?: boolean;
53
+ maxRetries?: number;
54
+ onProgress?: (done: number, total: number, chunksPerSec: number) => void;
55
+ workers?: number;
56
+ ann?: boolean;
57
+ }
58
+ export interface BuildIndexResult {
59
+ files: number;
60
+ skipped: number;
61
+ chunks: number;
62
+ reused: number;
63
+ reusedChunks: number;
64
+ reusedFiles: number;
65
+ embedded: number;
66
+ dim: number;
67
+ model: string;
68
+ vectorsPath: string;
69
+ }
70
+ export declare function buildIndex(opts: BuildIndexOptions): Promise<BuildIndexResult>;