edhindex 1.0.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.
@@ -0,0 +1,130 @@
1
+ import { EmbeddingProvider } from './provider.js';
2
+ import { MODEL_CONFIGS, RERANKER_MODEL, ModelTier } from '../config/index.js';
3
+ import { logger } from '../logging.js';
4
+
5
+ export class TransformersEmbeddingProvider implements EmbeddingProvider {
6
+ private pipeline: any = null;
7
+ private modelId: string;
8
+ private dims: number;
9
+ private modelTier: ModelTier;
10
+ private loaded = false;
11
+
12
+ constructor(tier: ModelTier) {
13
+ this.modelTier = tier;
14
+ const cfg = MODEL_CONFIGS[tier];
15
+ this.modelId = cfg.modelId;
16
+ this.dims = cfg.dimensions;
17
+ }
18
+
19
+ async load(): Promise<void> {
20
+ if (this.loaded) return;
21
+ try {
22
+ logger.info(`Loading embedding model: ${this.modelId}`);
23
+ const mod = await import('@huggingface/transformers');
24
+ this.pipeline = await mod.pipeline('feature-extraction', this.modelId, {
25
+ progress_callback: (progress: any) => {
26
+ if (progress?.status === 'progress' && progress?.percent !== undefined) {
27
+ logger.verbose(`Model download: ${Math.round(progress.percent)}%`);
28
+ }
29
+ },
30
+ });
31
+ this.loaded = true;
32
+ logger.info(`Embedding model loaded: ${this.modelId}`);
33
+ } catch (e) {
34
+ logger.error('Failed to load embedding model:', e);
35
+ throw e;
36
+ }
37
+ }
38
+
39
+ async embed(texts: string[]): Promise<number[][]> {
40
+ if (!this.loaded) {
41
+ await this.load();
42
+ }
43
+
44
+ const results: number[][] = [];
45
+ for (const text of texts) {
46
+ try {
47
+ const output = await this.pipeline(text, {
48
+ pooling: 'mean',
49
+ normalize: true,
50
+ });
51
+ const embedding = output.tolist ? output.tolist() : output.data || output;
52
+ results.push(Array.isArray(embedding[0]) ? embedding[0] : embedding);
53
+ } catch (e) {
54
+ logger.debug('Embedding failed for text, using zero vector:', e);
55
+ results.push(new Array(this.dims).fill(0));
56
+ }
57
+ }
58
+ return results;
59
+ }
60
+
61
+ dimensions(): number {
62
+ return this.dims;
63
+ }
64
+
65
+ name(): string {
66
+ return this.modelTier;
67
+ }
68
+
69
+ dispose(): void {
70
+ this.pipeline = null;
71
+ this.loaded = false;
72
+ }
73
+ }
74
+
75
+ export class TransformersReranker {
76
+ private pipeline: any = null;
77
+ private loaded = false;
78
+
79
+ async load(): Promise<void> {
80
+ if (this.loaded) return;
81
+ try {
82
+ logger.info('Loading reranker model');
83
+ const mod = await import('@huggingface/transformers');
84
+ this.pipeline = await mod.pipeline('text-classification', RERANKER_MODEL, {
85
+ progress_callback: (progress: any) => {
86
+ if (progress?.status === 'progress' && progress?.percent !== undefined) {
87
+ logger.verbose(`Reranker download: ${Math.round(progress.percent)}%`);
88
+ }
89
+ },
90
+ });
91
+ this.loaded = true;
92
+ logger.info('Reranker model loaded');
93
+ } catch (e) {
94
+ logger.error('Failed to load reranker model:', e);
95
+ throw e;
96
+ }
97
+ }
98
+
99
+ async rerank(query: string, texts: string[]): Promise<{ index: number; score: number }[]> {
100
+ if (!this.loaded) {
101
+ await this.load();
102
+ }
103
+
104
+ const pairs = texts.map(text => ({ text: query, text_pair: text }));
105
+
106
+ try {
107
+ const results = await this.pipeline(pairs);
108
+ const scores: { index: number; score: number }[] = [];
109
+
110
+ for (let i = 0; i < results.length; i++) {
111
+ const result = results[i];
112
+ const score = result.score !== undefined
113
+ ? (result.label === 'LABEL_1' ? result.score : 1 - result.score)
114
+ : 0;
115
+ scores.push({ index: i, score });
116
+ }
117
+
118
+ scores.sort((a, b) => b.score - a.score);
119
+ return scores;
120
+ } catch (e) {
121
+ logger.debug('Reranking failed:', e);
122
+ return texts.map((_, i) => ({ index: i, score: 0 }));
123
+ }
124
+ }
125
+
126
+ dispose(): void {
127
+ this.pipeline = null;
128
+ this.loaded = false;
129
+ }
130
+ }
@@ -0,0 +1,198 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { SymbolInfo } from './parser.js';
3
+ import { logger } from '../logging.js';
4
+
5
+ export interface Chunk {
6
+ id: string;
7
+ file: string;
8
+ language: string;
9
+ symbol: string;
10
+ kind: string | null;
11
+ parent: string | null;
12
+ hash: string;
13
+ startLine: number;
14
+ endLine: number;
15
+ content: string;
16
+ imports: string[];
17
+ exports: string[];
18
+ }
19
+
20
+ const MAX_TOKENS_PER_CHUNK = 512;
21
+
22
+ function estimateTokens(text: string): number {
23
+ return Math.ceil(text.length / 4);
24
+ }
25
+
26
+ function computeHash(content: string): string {
27
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
28
+ }
29
+
30
+ export async function chunkFile(
31
+ filePath: string,
32
+ relativePath: string,
33
+ language: string,
34
+ symbols: SymbolInfo[],
35
+ content: string,
36
+ lines: string[],
37
+ imports: string[],
38
+ exports: string[],
39
+ ): Promise<Chunk[]> {
40
+ const chunks: Chunk[] = [];
41
+ const priorityOrder: Record<string, number> = {
42
+ function: 0,
43
+ method: 1,
44
+ class: 2,
45
+ interface: 3,
46
+ enum: 4,
47
+ module: 5,
48
+ };
49
+
50
+ const sorted = [...symbols].sort((a, b) => {
51
+ const aP = priorityOrder[a.kind] ?? 99;
52
+ const bP = priorityOrder[b.kind] ?? 99;
53
+ if (aP !== bP) return aP - bP;
54
+ return a.startLine - b.startLine;
55
+ });
56
+
57
+ const covered = new Set<number>();
58
+
59
+ for (const sym of sorted) {
60
+ if (isCovered(covered, sym.startLine, sym.endLine)) continue;
61
+ markCovered(covered, sym.startLine, sym.endLine);
62
+
63
+ const chunkContent = sym.text || content.slice(
64
+ content.split('\n').slice(0, sym.startLine - 1).join('\n').length + (sym.startLine > 1 ? 1 : 0),
65
+ content.split('\n').slice(0, sym.endLine).join('\n').length
66
+ );
67
+
68
+ if (estimateTokens(chunkContent) <= MAX_TOKENS_PER_CHUNK) {
69
+ const hash = computeHash(chunkContent);
70
+ chunks.push({
71
+ id: `${relativePath}:${sym.startLine}-${sym.endLine}`,
72
+ file: relativePath,
73
+ language,
74
+ symbol: sym.name,
75
+ kind: sym.kind,
76
+ parent: null,
77
+ hash,
78
+ startLine: sym.startLine,
79
+ endLine: sym.endLine,
80
+ content: chunkContent,
81
+ imports,
82
+ exports,
83
+ });
84
+ } else {
85
+ const childChunks = splitRecursive(
86
+ chunkContent, lines, sym.startLine, sym.name, sym.kind,
87
+ relativePath, language, imports, exports
88
+ );
89
+ chunks.push(...childChunks);
90
+ }
91
+ }
92
+
93
+ const fileContent = content;
94
+ if (estimateTokens(fileContent) <= MAX_TOKENS_PER_CHUNK * 2 && covered.size < lines.length) {
95
+ const uncoveredLines = getUncoveredLines(lines, covered);
96
+ if (uncoveredLines.length > 0 && estimateTokens(uncoveredLines.join('\n')) <= MAX_TOKENS_PER_CHUNK) {
97
+ const startLine = uncoveredLines[0].line;
98
+ const endLine = uncoveredLines[uncoveredLines.length - 1].line;
99
+ const hash = computeHash(uncoveredLines.map(l => l.text).join('\n'));
100
+ chunks.push({
101
+ id: `${relativePath}:${startLine}-${endLine}`,
102
+ file: relativePath,
103
+ language,
104
+ symbol: `file_${relativePath.replace(/[^a-zA-Z0-9]/g, '_')}`,
105
+ kind: 'module',
106
+ parent: null,
107
+ hash,
108
+ startLine,
109
+ endLine,
110
+ content: uncoveredLines.map(l => l.text).join('\n'),
111
+ imports,
112
+ exports,
113
+ });
114
+ }
115
+ }
116
+
117
+ return chunks;
118
+ }
119
+
120
+ function splitRecursive(
121
+ text: string,
122
+ allLines: string[],
123
+ baseLine: number,
124
+ symbolName: string,
125
+ kind: string | null,
126
+ relativePath: string,
127
+ language: string,
128
+ imports: string[],
129
+ exports: string[],
130
+ ): Chunk[] {
131
+ const chunks: Chunk[] = [];
132
+ const textLines = text.split('\n');
133
+ const lineCount = textLines.length;
134
+
135
+ let currentStart = 0;
136
+ while (currentStart < lineCount) {
137
+ const remaining = lineCount - currentStart;
138
+ let end = currentStart + Math.min(remaining, Math.floor(MAX_TOKENS_PER_CHUNK * 2));
139
+
140
+ let chunkText = textLines.slice(currentStart, end).join('\n');
141
+ while (estimateTokens(chunkText) > MAX_TOKENS_PER_CHUNK && end > currentStart + 1) {
142
+ end--;
143
+ chunkText = textLines.slice(currentStart, end).join('\n');
144
+ }
145
+
146
+ if (end <= currentStart) {
147
+ chunkText = textLines[currentStart];
148
+ end = currentStart + 1;
149
+ }
150
+
151
+ const hash = computeHash(chunkText);
152
+ chunks.push({
153
+ id: `${relativePath}:${baseLine + currentStart}-${baseLine + end - 1}`,
154
+ file: relativePath,
155
+ language,
156
+ symbol: symbolName,
157
+ kind,
158
+ parent: `${relativePath}:${baseLine}-${baseLine + lineCount - 1}`,
159
+ hash,
160
+ startLine: baseLine + currentStart,
161
+ endLine: baseLine + end - 1,
162
+ content: chunkText,
163
+ imports,
164
+ exports,
165
+ });
166
+
167
+ currentStart = end;
168
+ }
169
+
170
+ return chunks;
171
+ }
172
+
173
+ function isCovered(covered: Set<number>, start: number, end: number): boolean {
174
+ for (let i = start; i <= end; i++) {
175
+ if (covered.has(i)) return true;
176
+ }
177
+ return false;
178
+ }
179
+
180
+ function markCovered(covered: Set<number>, start: number, end: number) {
181
+ for (let i = start; i <= end; i++) {
182
+ covered.add(i);
183
+ }
184
+ }
185
+
186
+ function getUncoveredLines(lines: string[], covered: Set<number>): { line: number; text: string }[] {
187
+ const result: { line: number; text: string }[] = [];
188
+ for (let i = 0; i < lines.length; i++) {
189
+ if (!covered.has(i + 1)) {
190
+ result.push({ line: i + 1, text: lines[i] });
191
+ }
192
+ }
193
+ return result;
194
+ }
195
+
196
+ export function chunkHash(content: string): string {
197
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
198
+ }
@@ -0,0 +1,139 @@
1
+ import { readdirSync, statSync, readFileSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { checkSymlinkSafety } from '../security.js';
5
+ import { createIgnoreRules, IgnoreRules } from './ignore.js';
6
+ import { logger } from '../logging.js';
7
+
8
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
9
+
10
+ const BINARY_EXTENSIONS = new Set([
11
+ '.exe', '.dll', '.so', '.dylib', '.bin', '.wasm',
12
+ '.o', '.a', '.lib',
13
+ '.pyc', '.class', '.jar', '.war',
14
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp', '.bmp', '.tiff',
15
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
16
+ '.zip', '.tar', '.gz', '.bz2', '.7z', '.rar',
17
+ '.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv',
18
+ '.mp3', '.wav', '.flac', '.ogg',
19
+ ]);
20
+
21
+ const LANGUAGE_EXTENSIONS: Record<string, string[]> = {
22
+ ts: ['.ts', '.tsx', '.mts', '.cts'],
23
+ js: ['.js', '.jsx', '.mjs', '.cjs'],
24
+ py: ['.py', '.pyw'],
25
+ go: ['.go'],
26
+ };
27
+
28
+ const EXTENSION_TO_LANG: Record<string, string> = {};
29
+ for (const [lang, exts] of Object.entries(LANGUAGE_EXTENSIONS)) {
30
+ for (const ext of exts) {
31
+ EXTENSION_TO_LANG[ext] = lang;
32
+ }
33
+ }
34
+
35
+ export function getLanguageForFile(filePath: string): string | null {
36
+ const ext = filePath.toLowerCase().split('.').pop();
37
+ return EXTENSION_TO_LANG[`.${ext}`] || null;
38
+ }
39
+
40
+ export function languageFromExtension(ext: string): string | null {
41
+ return EXTENSION_TO_LANG[ext.toLowerCase()] || null;
42
+ }
43
+
44
+ export function isIndexableFile(filePath: string, stat: { size: number }): boolean {
45
+ if (stat.size > MAX_FILE_SIZE) return false;
46
+ if (stat.size === 0) return false;
47
+ const ext = filePath.toLowerCase().split('.').pop();
48
+ if (BINARY_EXTENSIONS.has(`.${ext}`)) return false;
49
+ return true;
50
+ }
51
+
52
+ export function isGeneratedFile(filePath: string): boolean {
53
+ const name = filePath.split('/').pop() || filePath;
54
+ return name.endsWith('.generated.ts') || name.endsWith('.g.ts') ||
55
+ name === 'tsconfig.json' || name === 'package.json' ||
56
+ name.endsWith('.min.js') || name.endsWith('.min.css');
57
+ }
58
+
59
+ export function computeHash(content: string): string {
60
+ return createHash('sha256').update(content, 'utf-8').digest('hex');
61
+ }
62
+
63
+ export function computeFileHash(filePath: string): string {
64
+ const content = readFileSync(filePath, 'utf-8');
65
+ return computeHash(content);
66
+ }
67
+
68
+ export interface ScannedFile {
69
+ path: string;
70
+ relativePath: string;
71
+ language: string;
72
+ hash: string;
73
+ size: number;
74
+ }
75
+
76
+ export function scanFiles(rootPath: string, languages: string[]): ScannedFile[] {
77
+ const ignore = createIgnoreRules(rootPath);
78
+ const results: ScannedFile[] = [];
79
+
80
+ const allowedExts = new Set<string>();
81
+ for (const lang of languages) {
82
+ const exts = LANGUAGE_EXTENSIONS[lang];
83
+ if (exts) for (const ext of exts) allowedExts.add(ext);
84
+ }
85
+
86
+ function walk(dir: string) {
87
+ let entries: string[];
88
+ try {
89
+ entries = readdirSync(dir);
90
+ } catch {
91
+ return;
92
+ }
93
+
94
+ for (const entry of entries) {
95
+ const fullPath = join(dir, entry);
96
+ const relPath = relative(rootPath, fullPath).replace(/\\/g, '/');
97
+
98
+ if (ignore.isIgnored(relPath)) continue;
99
+
100
+ try {
101
+ const stat = statSync(fullPath);
102
+ if (stat.isDirectory()) {
103
+ walk(fullPath);
104
+ } else if (stat.isFile() || stat.isSymbolicLink()) {
105
+ if (stat.isSymbolicLink()) {
106
+ if (!checkSymlinkSafety(rootPath, fullPath)) continue;
107
+ }
108
+
109
+ const ext = '.' + (entry.split('.').pop() || '').toLowerCase();
110
+ if (!allowedExts.has(ext)) continue;
111
+ if (!isIndexableFile(fullPath, stat)) continue;
112
+
113
+ const lang = EXTENSION_TO_LANG[ext];
114
+ if (!lang) continue;
115
+
116
+ try {
117
+ const hash = computeFileHash(fullPath);
118
+ results.push({
119
+ path: fullPath,
120
+ relativePath: relPath,
121
+ language: lang,
122
+ hash,
123
+ size: stat.size,
124
+ });
125
+ } catch {
126
+ // skip unreadable files
127
+ }
128
+ }
129
+ } catch {
130
+ // skip inaccessible entries
131
+ }
132
+ }
133
+ }
134
+
135
+ walk(rootPath);
136
+ return results;
137
+ }
138
+
139
+
@@ -0,0 +1,100 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { join, relative, isAbsolute, sep } from 'node:path';
3
+
4
+ const IGNORE_FILE = '.edhindexignore';
5
+
6
+ const DEFAULT_IGNORE_PATTERNS = [
7
+ 'node_modules',
8
+ '.git',
9
+ 'dist',
10
+ 'build',
11
+ 'coverage',
12
+ '.next',
13
+ 'out',
14
+ 'vendor',
15
+ 'bin',
16
+ 'obj',
17
+ 'target',
18
+ 'tmp',
19
+ '.cache',
20
+ '.turbo',
21
+ '.edhindex',
22
+ '*.exe', '*.dll', '*.so', '*.dylib', '*.bin', '*.wasm',
23
+ '*.o', '*.a', '*.lib',
24
+ '*.pyc', '*.class', '*.jar', '*.war',
25
+ '*.png', '*.jpg', '*.jpeg', '*.gif', '*.svg', '*.ico', '*.webp', '*.bmp', '*.tiff',
26
+ '*.pdf', '*.doc', '*.docx', '*.xls', '*.xlsx', '*.ppt', '*.pptx',
27
+ '*.zip', '*.tar', '*.gz', '*.bz2', '*.7z', '*.rar',
28
+ '*.mp4', '*.avi', '*.mov', '*.wmv', '*.flv', '*.mkv',
29
+ '*.mp3', '*.wav', '*.flac', '*.ogg',
30
+ '*.min.js', '*.min.css',
31
+ 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
32
+ ];
33
+
34
+ export interface IgnoreRules {
35
+ isIgnored(relativePath: string): boolean;
36
+ }
37
+
38
+ function globToRegex(pattern: string): RegExp {
39
+ const isNegated = pattern.startsWith('!');
40
+ const p = isNegated ? pattern.slice(1) : pattern;
41
+
42
+ let regexStr = '^';
43
+ for (let i = 0; i < p.length; i++) {
44
+ const ch = p[i];
45
+ if (ch === '*') {
46
+ if (i + 1 < p.length && p[i + 1] === '*') {
47
+ regexStr += '.*';
48
+ i++;
49
+ if (i + 1 < p.length && (p[i + 1] === '/' || p[i + 1] === '\\\\')) i++;
50
+ } else {
51
+ regexStr += '[^/]*';
52
+ }
53
+ } else if (ch === '?') {
54
+ regexStr += '[^/]';
55
+ } else if (ch === '.') {
56
+ regexStr += '\\.';
57
+ } else if (ch === '/') {
58
+ regexStr += '[/\\\\]';
59
+ } else if (ch === '\\') {
60
+ regexStr += '\\\\';
61
+ } else {
62
+ regexStr += ch;
63
+ }
64
+ }
65
+ regexStr += '$';
66
+
67
+ return new RegExp(regexStr, 'i');
68
+ }
69
+
70
+ export function createIgnoreRules(rootPath: string): IgnoreRules {
71
+ const patterns: { regex: RegExp; negated: boolean }[] = [];
72
+
73
+ for (const p of DEFAULT_IGNORE_PATTERNS) {
74
+ patterns.push({ regex: globToRegex(p), negated: false });
75
+ }
76
+
77
+ const ignoreFilePath = join(rootPath, IGNORE_FILE);
78
+ if (existsSync(ignoreFilePath)) {
79
+ const content = readFileSync(ignoreFilePath, 'utf-8');
80
+ for (const line of content.split('\n')) {
81
+ const trimmed = line.trim();
82
+ if (!trimmed || trimmed.startsWith('#')) continue;
83
+ const isNegated = trimmed.startsWith('!');
84
+ patterns.push({ regex: globToRegex(trimmed), negated: isNegated });
85
+ }
86
+ }
87
+
88
+ return {
89
+ isIgnored(relativePath: string): boolean {
90
+ const normalized = relativePath.replace(/\\/g, '/');
91
+ let ignored = false;
92
+ for (const p of patterns) {
93
+ if (p.regex.test(normalized) || p.regex.test(normalized.split('/').pop() || '')) {
94
+ ignored = !p.negated;
95
+ }
96
+ }
97
+ return ignored;
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,80 @@
1
+ import { logger } from '../logging.js';
2
+ import { ScannedFile } from './file-utils.js';
3
+
4
+ export interface FileDelta {
5
+ added: ScannedFile[];
6
+ modified: ScannedFile[];
7
+ removed: string[];
8
+ unchanged: ScannedFile[];
9
+ }
10
+
11
+ export function computeFileDelta(
12
+ scanned: Map<string, ScannedFile>,
13
+ existing: Map<string, string>, // relativePath -> hash
14
+ ): FileDelta {
15
+ const delta: FileDelta = {
16
+ added: [],
17
+ modified: [],
18
+ removed: [],
19
+ unchanged: [],
20
+ };
21
+
22
+ for (const [relPath, file] of scanned) {
23
+ const existingHash = existing.get(relPath);
24
+ if (existingHash === undefined) {
25
+ delta.added.push(file);
26
+ } else if (existingHash !== file.hash) {
27
+ delta.modified.push(file);
28
+ } else {
29
+ delta.unchanged.push(file);
30
+ }
31
+ }
32
+
33
+ const scannedPaths = new Set(scanned.keys());
34
+ for (const relPath of existing.keys()) {
35
+ if (!scannedPaths.has(relPath)) {
36
+ delta.removed.push(relPath);
37
+ }
38
+ }
39
+
40
+ return delta;
41
+ }
42
+
43
+ export interface ChunkDelta {
44
+ added: string[];
45
+ modified: string[];
46
+ removed: string[];
47
+ unchanged: string[];
48
+ }
49
+
50
+ export function computeChunkDelta(
51
+ newChunks: Map<string, string>,
52
+ existingChunks: Map<string, string>,
53
+ ): ChunkDelta {
54
+ const delta: ChunkDelta = {
55
+ added: [],
56
+ modified: [],
57
+ removed: [],
58
+ unchanged: [],
59
+ };
60
+
61
+ for (const [chunkId, hash] of newChunks) {
62
+ const existingHash = existingChunks.get(chunkId);
63
+ if (existingHash === undefined) {
64
+ delta.added.push(chunkId);
65
+ } else if (existingHash !== hash) {
66
+ delta.modified.push(chunkId);
67
+ } else {
68
+ delta.unchanged.push(chunkId);
69
+ }
70
+ }
71
+
72
+ const newIds = new Set(newChunks.keys());
73
+ for (const chunkId of existingChunks.keys()) {
74
+ if (!newIds.has(chunkId)) {
75
+ delta.removed.push(chunkId);
76
+ }
77
+ }
78
+
79
+ return delta;
80
+ }