@elyracode/semantic-index 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.md +48 -0
- package/extensions/index.ts +301 -0
- package/package.json +37 -0
- package/skills/elyra-semantic-index/SKILL.md +32 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @elyracode/semantic-index
|
|
2
|
+
|
|
3
|
+
Local semantic code search for Elyra. Embeds your codebase so the agent can find relevant code by meaning, not just keywords.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
elyra install npm:@elyracode/semantic-index
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Configuration
|
|
12
|
+
|
|
13
|
+
Configure the embeddings endpoint via environment variables. The defaults use the OpenAI API, but you can point at any OpenAI-compatible endpoint -- including fully local ones.
|
|
14
|
+
|
|
15
|
+
| Env var | Description | Default |
|
|
16
|
+
|---------|-------------|---------|
|
|
17
|
+
| `ELYRA_EMBED_BASE_URL` | OpenAI-compatible embeddings base URL. For fully local/private indexing, point at Ollama (`http://localhost:11434/v1`) or LM Studio. | `https://api.openai.com/v1` |
|
|
18
|
+
| `ELYRA_EMBED_MODEL` | Embedding model. For Ollama use `nomic-embed-text`. | `text-embedding-3-small` |
|
|
19
|
+
| `ELYRA_EMBED_API_KEY` | API key for the embeddings endpoint. Falls back to `OPENAI_API_KEY`. Not needed for local Ollama. | -- |
|
|
20
|
+
|
|
21
|
+
## Tools
|
|
22
|
+
|
|
23
|
+
| Tool | Description |
|
|
24
|
+
|------|-------------|
|
|
25
|
+
| `semantic_index_build` | Index the project's tracked source files. Run this once, and re-run after big changes. |
|
|
26
|
+
| `semantic_search` | Find code relevant to a natural-language query. |
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
The index is stored locally at `.elyra/semantic-index.json` and never leaves your machine -- the only network traffic is the embedding requests to your configured endpoint.
|
|
31
|
+
|
|
32
|
+
For full data sovereignty, use a local embeddings endpoint (such as Ollama) so nothing leaves your machine at all.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Once installed, ask Elyra to find code by meaning:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
> Where is rate limiting handled?
|
|
40
|
+
> How does the app verify webhook signatures?
|
|
41
|
+
> Find the code that retries failed jobs
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The agent builds the index with `semantic_index_build`, then uses `semantic_search` to locate relevant code.
|
|
45
|
+
|
|
46
|
+
## Skill
|
|
47
|
+
|
|
48
|
+
This package includes the `elyra-semantic-index` skill, which guides the agent on when and how to use semantic search effectively.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { ExtensionAPI } from "@elyracode/coding-agent";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
|
|
7
|
+
// ── Configuration ───────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
interface EmbedConfig {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
model: string;
|
|
12
|
+
apiKey: string | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getEmbedConfig(): EmbedConfig {
|
|
16
|
+
const baseUrl = (process.env.ELYRA_EMBED_BASE_URL || "https://api.openai.com/v1").replace(/\/+$/, "");
|
|
17
|
+
const model = process.env.ELYRA_EMBED_MODEL || "text-embedding-3-small";
|
|
18
|
+
const apiKey = process.env.ELYRA_EMBED_API_KEY || process.env.OPENAI_API_KEY;
|
|
19
|
+
return { baseUrl, model, apiKey };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Embeddings client ───────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Request embeddings for a batch of inputs from an OpenAI-compatible endpoint. */
|
|
25
|
+
async function embed(config: EmbedConfig, inputs: string[]): Promise<number[][]> {
|
|
26
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
27
|
+
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
|
|
28
|
+
|
|
29
|
+
const response = await fetch(`${config.baseUrl}/embeddings`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers,
|
|
32
|
+
body: JSON.stringify({ model: config.model, input: inputs }),
|
|
33
|
+
signal: AbortSignal.timeout(60_000),
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const text = await response.text();
|
|
37
|
+
throw new Error(`Embeddings request failed (${response.status}): ${text.slice(0, 300)}`);
|
|
38
|
+
}
|
|
39
|
+
const data = (await response.json()) as { data?: Array<{ embedding: number[] }> };
|
|
40
|
+
if (!data.data || data.data.length !== inputs.length) {
|
|
41
|
+
throw new Error("Embeddings response did not match the number of inputs");
|
|
42
|
+
}
|
|
43
|
+
return data.data.map((d) => d.embedding);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── File discovery & chunking ───────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
49
|
+
"ts", "tsx", "js", "jsx", "mjs", "cjs", "php", "py", "rb", "go", "rs", "java", "kt", "swift",
|
|
50
|
+
"c", "h", "cpp", "hpp", "cc", "cs", "scala", "ex", "exs", "vue", "svelte", "css", "scss",
|
|
51
|
+
"sql", "sh", "bash", "md", "json", "yaml", "yml", "toml",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
55
|
+
const CHUNK_LINES = 40;
|
|
56
|
+
const CHUNK_OVERLAP = 8;
|
|
57
|
+
|
|
58
|
+
interface Chunk {
|
|
59
|
+
file: string;
|
|
60
|
+
startLine: number;
|
|
61
|
+
endLine: number;
|
|
62
|
+
text: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** List tracked source files via git (respects .gitignore, skips node_modules). */
|
|
66
|
+
function listSourceFiles(cwd: string): string[] {
|
|
67
|
+
let output: string;
|
|
68
|
+
try {
|
|
69
|
+
output = execSync("git ls-files", { cwd, encoding: "utf-8", maxBuffer: 32 * 1024 * 1024 });
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
return output
|
|
74
|
+
.split("\n")
|
|
75
|
+
.map((f) => f.trim())
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.filter((f) => {
|
|
78
|
+
const ext = f.split(".").pop()?.toLowerCase();
|
|
79
|
+
return ext !== undefined && SOURCE_EXTENSIONS.has(ext);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Split a file into overlapping line-windows. */
|
|
84
|
+
function chunkFile(relPath: string, content: string): Chunk[] {
|
|
85
|
+
const lines = content.split("\n");
|
|
86
|
+
const chunks: Chunk[] = [];
|
|
87
|
+
const step = CHUNK_LINES - CHUNK_OVERLAP;
|
|
88
|
+
for (let start = 0; start < lines.length; start += step) {
|
|
89
|
+
const end = Math.min(start + CHUNK_LINES, lines.length);
|
|
90
|
+
const text = lines.slice(start, end).join("\n").trim();
|
|
91
|
+
if (text.length > 0) {
|
|
92
|
+
chunks.push({ file: relPath, startLine: start + 1, endLine: end, text });
|
|
93
|
+
}
|
|
94
|
+
if (end >= lines.length) break;
|
|
95
|
+
}
|
|
96
|
+
return chunks;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Index storage ───────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
interface IndexedChunk extends Chunk {
|
|
102
|
+
vector: number[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface IndexFile {
|
|
106
|
+
version: 1;
|
|
107
|
+
model: string;
|
|
108
|
+
createdAt: string;
|
|
109
|
+
chunks: IndexedChunk[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function indexPath(cwd: string): string {
|
|
113
|
+
return join(cwd, ".elyra", "semantic-index.json");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function saveIndex(cwd: string, index: IndexFile): void {
|
|
117
|
+
const path = indexPath(cwd);
|
|
118
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
119
|
+
const tmp = `${path}.tmp`;
|
|
120
|
+
writeFileSync(tmp, JSON.stringify(index));
|
|
121
|
+
renameSync(tmp, path); // atomic replace
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function loadIndex(cwd: string): IndexFile | null {
|
|
125
|
+
const path = indexPath(cwd);
|
|
126
|
+
if (!existsSync(path)) return null;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as IndexFile;
|
|
129
|
+
if (parsed.version === 1 && Array.isArray(parsed.chunks)) return parsed;
|
|
130
|
+
} catch {
|
|
131
|
+
// corrupt index
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Similarity ──────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function cosineSimilarity(a: number[], b: number[]): number {
|
|
139
|
+
let dot = 0;
|
|
140
|
+
let normA = 0;
|
|
141
|
+
let normB = 0;
|
|
142
|
+
const len = Math.min(a.length, b.length);
|
|
143
|
+
for (let i = 0; i < len; i++) {
|
|
144
|
+
dot += a[i] * b[i];
|
|
145
|
+
normA += a[i] * a[i];
|
|
146
|
+
normB += b[i] * b[i];
|
|
147
|
+
}
|
|
148
|
+
if (normA === 0 || normB === 0) return 0;
|
|
149
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ── Result helpers ──────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function ok(text: string) {
|
|
155
|
+
return { content: [{ type: "text" as const, text }] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function err(text: string) {
|
|
159
|
+
return { content: [{ type: "text" as const, text }], isError: true as const };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Extension ───────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
export default function (elyra: ExtensionAPI): void {
|
|
165
|
+
let cwd = "";
|
|
166
|
+
elyra.on("session_start", async (_event, ctx) => {
|
|
167
|
+
cwd = ctx.cwd;
|
|
168
|
+
});
|
|
169
|
+
const getCwd = () => cwd || process.cwd();
|
|
170
|
+
|
|
171
|
+
// ── semantic_index_build ─────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
elyra.registerTool({
|
|
174
|
+
name: "semantic_index_build",
|
|
175
|
+
label: "Build Semantic Index",
|
|
176
|
+
description:
|
|
177
|
+
"Index the project's tracked source files for semantic search. Reads each file, splits it into chunks, embeds them via the configured embeddings endpoint, and stores the index locally at .elyra/semantic-index.json. Run this once per project, and again after large changes. Requires an embeddings endpoint (ELYRA_EMBED_BASE_URL / ELYRA_EMBED_MODEL / ELYRA_EMBED_API_KEY or OPENAI_API_KEY).",
|
|
178
|
+
parameters: Type.Object({
|
|
179
|
+
max_files: Type.Optional(
|
|
180
|
+
Type.Integer({ description: "Cap the number of files indexed (useful for very large repos). Default: no cap." }),
|
|
181
|
+
),
|
|
182
|
+
}),
|
|
183
|
+
promptSnippet: "Build a semantic index of the codebase",
|
|
184
|
+
async execute(_id, params) {
|
|
185
|
+
const config = getEmbedConfig();
|
|
186
|
+
const cwdNow = getCwd();
|
|
187
|
+
|
|
188
|
+
let files = listSourceFiles(cwdNow);
|
|
189
|
+
if (files.length === 0) {
|
|
190
|
+
return err("No tracked source files found (is this a git repository?).");
|
|
191
|
+
}
|
|
192
|
+
if (params.max_files && params.max_files > 0) {
|
|
193
|
+
files = files.slice(0, params.max_files);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Build chunks from readable, reasonably-sized files.
|
|
197
|
+
const chunks: Chunk[] = [];
|
|
198
|
+
for (const rel of files) {
|
|
199
|
+
const abs = join(cwdNow, rel);
|
|
200
|
+
try {
|
|
201
|
+
if (statSync(abs).size > MAX_FILE_BYTES) continue;
|
|
202
|
+
const content = readFileSync(abs, "utf-8");
|
|
203
|
+
chunks.push(...chunkFile(rel, content));
|
|
204
|
+
} catch {
|
|
205
|
+
// skip unreadable files
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (chunks.length === 0) {
|
|
210
|
+
return err("No content to index after chunking.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Embed in batches.
|
|
214
|
+
const BATCH = 64;
|
|
215
|
+
const indexed: IndexedChunk[] = [];
|
|
216
|
+
try {
|
|
217
|
+
for (let i = 0; i < chunks.length; i += BATCH) {
|
|
218
|
+
const batch = chunks.slice(i, i + BATCH);
|
|
219
|
+
const vectors = await embed(config, batch.map((c) => c.text));
|
|
220
|
+
for (let j = 0; j < batch.length; j++) {
|
|
221
|
+
indexed.push({ ...batch[j], vector: vectors[j] });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
226
|
+
return err(`Failed to embed chunks: ${msg}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const index: IndexFile = {
|
|
230
|
+
version: 1,
|
|
231
|
+
model: config.model,
|
|
232
|
+
createdAt: new Date().toISOString(),
|
|
233
|
+
chunks: indexed,
|
|
234
|
+
};
|
|
235
|
+
try {
|
|
236
|
+
saveIndex(cwdNow, index);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
239
|
+
return err(`Failed to save index: ${msg}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return ok(
|
|
243
|
+
`Indexed ${indexed.length} chunks from ${files.length} files using ${config.model}. Saved to .elyra/semantic-index.json. Use semantic_search to query it.`,
|
|
244
|
+
);
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ── semantic_search ──────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
elyra.registerTool({
|
|
251
|
+
name: "semantic_search",
|
|
252
|
+
label: "Semantic Search",
|
|
253
|
+
description:
|
|
254
|
+
"Find code relevant to a natural-language query by meaning, not just keywords. Returns the most semantically similar code chunks with their file path and line range. Requires a built index (run semantic_index_build first). Use this to locate where a concept is implemented, or to gather relevant context before making changes.",
|
|
255
|
+
parameters: Type.Object({
|
|
256
|
+
query: Type.String({ description: "Natural-language description of what you're looking for" }),
|
|
257
|
+
limit: Type.Optional(Type.Integer({ description: "Number of results to return. Default 8, max 25." })),
|
|
258
|
+
}),
|
|
259
|
+
promptSnippet: "Search the codebase by meaning",
|
|
260
|
+
async execute(_id, params) {
|
|
261
|
+
const cwdNow = getCwd();
|
|
262
|
+
const index = loadIndex(cwdNow);
|
|
263
|
+
if (!index) {
|
|
264
|
+
return err("No semantic index found. Run semantic_index_build first.");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const config = getEmbedConfig();
|
|
268
|
+
if (index.model !== config.model) {
|
|
269
|
+
return err(
|
|
270
|
+
`Index was built with model "${index.model}" but the current model is "${config.model}". Rebuild with semantic_index_build.`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let queryVector: number[];
|
|
275
|
+
try {
|
|
276
|
+
[queryVector] = await embed(config, [params.query]);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
279
|
+
return err(`Failed to embed query: ${msg}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const limit = Math.min(Math.max(params.limit ?? 8, 1), 25);
|
|
283
|
+
const scored = index.chunks
|
|
284
|
+
.map((chunk) => ({ chunk, score: cosineSimilarity(queryVector, chunk.vector) }))
|
|
285
|
+
.sort((a, b) => b.score - a.score)
|
|
286
|
+
.slice(0, limit);
|
|
287
|
+
|
|
288
|
+
if (scored.length === 0) {
|
|
289
|
+
return ok("No matching chunks.");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const lines: string[] = [`Top ${scored.length} matches for: ${params.query}\n`];
|
|
293
|
+
for (const { chunk, score } of scored) {
|
|
294
|
+
lines.push(`── ${chunk.file}:${chunk.startLine}-${chunk.endLine} (score ${score.toFixed(3)})`);
|
|
295
|
+
lines.push(chunk.text);
|
|
296
|
+
lines.push("");
|
|
297
|
+
}
|
|
298
|
+
return ok(lines.join("\n"));
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elyracode/semantic-index",
|
|
3
|
+
"version": "0.9.3",
|
|
4
|
+
"description": "Local semantic code search for Elyra -- embed your codebase and find relevant code by meaning, not just keywords",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"elyra-package",
|
|
8
|
+
"semantic-search",
|
|
9
|
+
"embeddings",
|
|
10
|
+
"code-search",
|
|
11
|
+
"rag"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Knut W. Horne",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/kwhorne/elyra.git",
|
|
18
|
+
"directory": "packages/semantic-index"
|
|
19
|
+
},
|
|
20
|
+
"elyra": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./extensions/index.ts"
|
|
23
|
+
],
|
|
24
|
+
"skills": [
|
|
25
|
+
"./skills"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@elyracode/coding-agent": "*",
|
|
30
|
+
"typebox": "*"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"clean": "echo 'nothing to clean'",
|
|
34
|
+
"build": "echo 'nothing to build'",
|
|
35
|
+
"check": "echo 'nothing to check'"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: elyra-semantic-index
|
|
3
|
+
description: Find code by meaning, not just keywords. Use when looking for where something is implemented, how a concept is handled across the codebase, or finding relevant code before making changes -- especially in large or unfamiliar codebases.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Semantic Code Search
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
Use semantic search when:
|
|
11
|
+
- You need to find where something is implemented but don't know the exact symbol or file name
|
|
12
|
+
- You want to understand how a concept (auth, retries, caching, validation) is handled across the codebase
|
|
13
|
+
- You're gathering relevant code before making a change
|
|
14
|
+
- You're working in a large or unfamiliar codebase where grep by keyword isn't enough
|
|
15
|
+
|
|
16
|
+
## Available Tools
|
|
17
|
+
|
|
18
|
+
| Tool | Use when |
|
|
19
|
+
|------|----------|
|
|
20
|
+
| `semantic_index_build` | Build or refresh the index of the project's tracked source files |
|
|
21
|
+
| `semantic_search` | Find code relevant to a natural-language query |
|
|
22
|
+
|
|
23
|
+
## Workflow
|
|
24
|
+
|
|
25
|
+
1. Build the index once per project with `semantic_index_build`. Re-run it after large changes to keep results accurate.
|
|
26
|
+
2. Use `semantic_search` with a natural-language query to find conceptually relevant code.
|
|
27
|
+
3. Combine approaches: use `grep` for exact symbol or string matches, and `semantic_search` for conceptual matches when you don't know the exact terms.
|
|
28
|
+
|
|
29
|
+
## Notes
|
|
30
|
+
|
|
31
|
+
- If `semantic_search` reports that no index exists, run `semantic_index_build` first, then retry the query.
|
|
32
|
+
- Requires an embeddings endpoint configured via environment variables (`ELYRA_EMBED_BASE_URL`, `ELYRA_EMBED_MODEL`, `ELYRA_EMBED_API_KEY`). A local endpoint such as Ollama keeps everything on your machine.
|