embarsy-qdrant-mcp 0.1.0 → 0.1.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.
- package/dist/bin/index.js +2 -1
- package/dist/chunker.js +40 -50
- package/dist/config.js +2 -1
- package/dist/embeddings.js +11 -4
- package/dist/http.js +1 -1
- package/dist/indexer.js +21 -1
- package/dist/qdrant.js +8 -1
- package/package.json +1 -1
package/dist/bin/index.js
CHANGED
|
@@ -60,7 +60,8 @@ async function main() {
|
|
|
60
60
|
const started = Date.now();
|
|
61
61
|
const result = await indexRepo(rootAbs, cfg, { onProgress: (m) => process.stdout.write(m + "\n") });
|
|
62
62
|
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
63
|
-
|
|
63
|
+
const minified = result.skippedMinified > 0 ? `, ${result.skippedMinified} skipped (minified)` : "";
|
|
64
|
+
process.stdout.write(`\nDone in ${secs}s — ${result.indexed} indexed, ${result.skipped} unchanged${minified}, ${result.removed} removed; ${result.chunks} chunks upserted into "${cfg.collection}".\n`);
|
|
64
65
|
}
|
|
65
66
|
main().catch((err) => {
|
|
66
67
|
process.stderr.write(`\nembarsy-index failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
package/dist/chunker.js
CHANGED
|
@@ -1,61 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Character-window chunking with a HARD size cap, so no single chunk can ever exceed the
|
|
3
|
+
* embedding model's context — not even a minified/bundled file that is one enormous line.
|
|
4
|
+
*
|
|
5
|
+
* Windows prefer to end at a newline (cleaner code boundaries), but a line longer than the cap
|
|
6
|
+
* is split by character count instead of being embedded whole. The old line-based chunker sent
|
|
7
|
+
* a one-line 800 KB JSON as a single chunk, which tokenised past 32k and crashed llama-server.
|
|
5
8
|
*/
|
|
6
|
-
const BOUNDARY = /^\s*(export\s+)?(async\s+)?(function|class|struct|enum|interface|trait|impl|def|func|fn|type|module|namespace|public|private|protected|static)\b/;
|
|
7
9
|
export function chunkFile(content, cfg) {
|
|
8
|
-
const lines = content.split(/\r?\n/);
|
|
9
|
-
const chunks = [];
|
|
10
10
|
const maxChars = Math.max(400, cfg.chunkMaxChars);
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
const overlap = Math.max(0, Math.min(cfg.chunkOverlapChars, Math.floor(maxChars / 2)));
|
|
12
|
+
if (content.trim().length === 0)
|
|
13
|
+
return [];
|
|
14
|
+
// Line-start offsets → 1-based line number for any character offset (binary search).
|
|
15
|
+
const lineStarts = [0];
|
|
16
|
+
for (let i = 0; i < content.length; i++) {
|
|
17
|
+
if (content.charCodeAt(i) === 10 /* \n */)
|
|
18
|
+
lineStarts.push(i + 1);
|
|
19
|
+
}
|
|
20
|
+
const lineAt = (offset) => {
|
|
21
|
+
let lo = 0, hi = lineStarts.length - 1;
|
|
22
|
+
while (lo < hi) {
|
|
23
|
+
const mid = (lo + hi + 1) >> 1;
|
|
24
|
+
if (lineStarts[mid] <= offset)
|
|
25
|
+
lo = mid;
|
|
26
|
+
else
|
|
27
|
+
hi = mid - 1;
|
|
22
28
|
}
|
|
29
|
+
return lo + 1;
|
|
23
30
|
};
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let pos = 0;
|
|
33
|
+
while (pos < content.length) {
|
|
34
|
+
let end = Math.min(content.length, pos + maxChars);
|
|
35
|
+
if (end < content.length) {
|
|
36
|
+
// Snap to the last newline in the back half of the window for a tidy cut; a window with
|
|
37
|
+
// no newline (one giant line) falls through and is hard-cut at maxChars.
|
|
38
|
+
const nl = content.lastIndexOf("\n", end);
|
|
39
|
+
if (nl > pos + Math.floor(maxChars / 2))
|
|
40
|
+
end = nl;
|
|
32
41
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
flush(i);
|
|
37
|
-
startIdx = backfillOverlap(lines, i, overlapChars);
|
|
38
|
-
curChars = charsBetween(lines, startIdx, i);
|
|
42
|
+
const text = content.slice(pos, end);
|
|
43
|
+
if (text.trim().length > 0) {
|
|
44
|
+
chunks.push({ startLine: lineAt(pos), endLine: lineAt(Math.max(pos, end - 1)), text });
|
|
39
45
|
}
|
|
46
|
+
if (end >= content.length)
|
|
47
|
+
break;
|
|
48
|
+
pos = Math.max(pos + 1, end - overlap);
|
|
40
49
|
}
|
|
41
|
-
flush(lines.length);
|
|
42
50
|
return chunks;
|
|
43
51
|
}
|
|
44
|
-
/** Walk back from `idx` to include ~overlapChars of trailing context; returns new start index. */
|
|
45
|
-
function backfillOverlap(lines, idx, overlapChars) {
|
|
46
|
-
if (overlapChars <= 0)
|
|
47
|
-
return idx;
|
|
48
|
-
let chars = 0;
|
|
49
|
-
let j = idx;
|
|
50
|
-
while (j > 0 && chars < overlapChars) {
|
|
51
|
-
j--;
|
|
52
|
-
chars += (lines[j]?.length ?? 0) + 1;
|
|
53
|
-
}
|
|
54
|
-
return j;
|
|
55
|
-
}
|
|
56
|
-
function charsBetween(lines, from, to) {
|
|
57
|
-
let c = 0;
|
|
58
|
-
for (let k = from; k < to; k++)
|
|
59
|
-
c += (lines[k]?.length ?? 0) + 1;
|
|
60
|
-
return c;
|
|
61
|
-
}
|
package/dist/config.js
CHANGED
|
@@ -17,8 +17,9 @@ export function loadConfig(overrides = {}) {
|
|
|
17
17
|
collection: env.QDRANT_COLLECTION_NAME ?? env.QDRANT_COLLECTION ?? "",
|
|
18
18
|
chunkMaxChars: intEnv(env.EMBARSY_CHUNK_CHARS, 1500),
|
|
19
19
|
chunkOverlapChars: intEnv(env.EMBARSY_CHUNK_OVERLAP, 200),
|
|
20
|
-
embedBatch: intEnv(env.EMBARSY_EMBED_BATCH,
|
|
20
|
+
embedBatch: intEnv(env.EMBARSY_EMBED_BATCH, 32),
|
|
21
21
|
maxFileBytes: intEnv(env.EMBARSY_MAX_FILE_BYTES, 1_000_000),
|
|
22
|
+
maxLineChars: intEnv(env.EMBARSY_MAX_LINE_CHARS, 5000),
|
|
22
23
|
...overrides,
|
|
23
24
|
};
|
|
24
25
|
}
|
package/dist/embeddings.js
CHANGED
|
@@ -4,20 +4,27 @@ import { requestJSON } from "./http.js";
|
|
|
4
4
|
* This is the piece other tools get wrong: we send a bearer API key and use the standard
|
|
5
5
|
* POST /v1/embeddings shape, so Embarsy authenticates the call and counts it in Monitoring.
|
|
6
6
|
*/
|
|
7
|
+
/** Absolute per-input cap (defense in depth on top of chunking) so no single text can overflow
|
|
8
|
+
* the model's context and crash the embedding backend, whatever produced it. */
|
|
9
|
+
const MAX_EMBED_CHARS = 8000;
|
|
7
10
|
export async function embedBatch(texts, cfg) {
|
|
8
11
|
if (texts.length === 0)
|
|
9
12
|
return [];
|
|
13
|
+
const input = texts.map((t) => (t.length > MAX_EMBED_CHARS ? t.slice(0, MAX_EMBED_CHARS) : t));
|
|
10
14
|
const json = await requestJSON(`${cfg.openaiBaseUrl}/embeddings`, {
|
|
11
15
|
method: "POST",
|
|
12
16
|
headers: {
|
|
13
17
|
"Content-Type": "application/json",
|
|
14
18
|
Authorization: `Bearer ${cfg.openaiApiKey}`,
|
|
15
19
|
},
|
|
16
|
-
body: JSON.stringify({ model: cfg.embeddingModel, input
|
|
17
|
-
},
|
|
20
|
+
body: JSON.stringify({ model: cfg.embeddingModel, input }),
|
|
21
|
+
},
|
|
22
|
+
// Extra retries: if the embedding backend hiccups (e.g. Ollama restarts llama-server), ride
|
|
23
|
+
// out the restart instead of aborting the whole index.
|
|
24
|
+
{ label: "embeddings", retries: 4 });
|
|
18
25
|
const data = json?.data;
|
|
19
|
-
if (!Array.isArray(data) || data.length !==
|
|
20
|
-
throw new Error(`Unexpected embeddings response (got ${Array.isArray(data) ? data.length : "no"} vectors for ${
|
|
26
|
+
if (!Array.isArray(data) || data.length !== input.length) {
|
|
27
|
+
throw new Error(`Unexpected embeddings response (got ${Array.isArray(data) ? data.length : "no"} vectors for ${input.length} inputs).`);
|
|
21
28
|
}
|
|
22
29
|
// Respect the `index` field so ordering is guaranteed to match the input.
|
|
23
30
|
const ordered = [...data].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
|
package/dist/http.js
CHANGED
|
@@ -26,7 +26,7 @@ export async function requestJSON(url, init, opts = {}) {
|
|
|
26
26
|
throw e;
|
|
27
27
|
}
|
|
28
28
|
if (attempt < retries)
|
|
29
|
-
await sleep(
|
|
29
|
+
await sleep(Math.min(5000, 400 * 2 ** attempt)); // exp backoff, capped 5s
|
|
30
30
|
}
|
|
31
31
|
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
32
32
|
}
|
package/dist/indexer.js
CHANGED
|
@@ -14,6 +14,21 @@ function looksBinary(s) {
|
|
|
14
14
|
}
|
|
15
15
|
return false;
|
|
16
16
|
}
|
|
17
|
+
/** Longest line length — a line far longer than any hand-written source line flags a minified
|
|
18
|
+
* / bundled / single-blob file (a poor embedding candidate that also stresses the model). */
|
|
19
|
+
function maxLineLength(s) {
|
|
20
|
+
let max = 0, cur = 0;
|
|
21
|
+
for (let i = 0; i < s.length; i++) {
|
|
22
|
+
if (s.charCodeAt(i) === 10) {
|
|
23
|
+
if (cur > max)
|
|
24
|
+
max = cur;
|
|
25
|
+
cur = 0;
|
|
26
|
+
}
|
|
27
|
+
else
|
|
28
|
+
cur++;
|
|
29
|
+
}
|
|
30
|
+
return cur > max ? cur : max;
|
|
31
|
+
}
|
|
17
32
|
/** Full/incremental index of a directory into the configured Qdrant collection. */
|
|
18
33
|
export async function indexRepo(root, cfg, opts = {}) {
|
|
19
34
|
const log = opts.onProgress ?? (() => { });
|
|
@@ -23,7 +38,7 @@ export async function indexRepo(root, cfg, opts = {}) {
|
|
|
23
38
|
log(`Found ${files.length} indexable files in ${rootAbs}`);
|
|
24
39
|
const existing = await scrollFileHashes(cfg);
|
|
25
40
|
const seen = new Set();
|
|
26
|
-
const result = { files: files.length, indexed: 0, skipped: 0, removed: 0, chunks: 0 };
|
|
41
|
+
const result = { files: files.length, indexed: 0, skipped: 0, removed: 0, chunks: 0, skippedMinified: 0 };
|
|
27
42
|
// Pending chunk buffer, flushed in embedding batches across files.
|
|
28
43
|
let pending = [];
|
|
29
44
|
const flush = async () => {
|
|
@@ -50,6 +65,11 @@ export async function indexRepo(root, cfg, opts = {}) {
|
|
|
50
65
|
}
|
|
51
66
|
if (looksBinary(content))
|
|
52
67
|
continue;
|
|
68
|
+
if (maxLineLength(content) > cfg.maxLineChars) {
|
|
69
|
+
result.skippedMinified++;
|
|
70
|
+
log(` skipped (minified/generated): ${file.rel}`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
53
73
|
const hash = sha256(content);
|
|
54
74
|
if (existing.get(file.rel) === hash) {
|
|
55
75
|
result.skipped++;
|
package/dist/qdrant.js
CHANGED
|
@@ -20,7 +20,14 @@ export async function ensureCollection(cfg) {
|
|
|
20
20
|
await requestJSON(base(cfg), {
|
|
21
21
|
method: "PUT",
|
|
22
22
|
headers: headers(cfg),
|
|
23
|
-
body: JSON.stringify({
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
// Laptop-friendly storage: full-precision originals live on disk and are only
|
|
25
|
+
// read to rescore the top candidates; searches run on an in-RAM int8 copy
|
|
26
|
+
// (~4x smaller, SIMD-accelerated) with no practical recall loss for cosine
|
|
27
|
+
// text embeddings.
|
|
28
|
+
vectors: { size: cfg.embeddingDimension, distance: "Cosine", on_disk: true },
|
|
29
|
+
quantization_config: { scalar: { type: "int8", quantile: 0.99, always_ram: true } },
|
|
30
|
+
}),
|
|
24
31
|
}, { label: "create collection", retries: 0 });
|
|
25
32
|
// Payload index on file_path enables fast delete-by-file for incremental reindex.
|
|
26
33
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "embarsy-qdrant-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Codebase-indexing MCP server for Embarsy — semantic code search over a local Qdrant + OpenAI-compatible embeddings stack. Routes embeddings and Qdrant through Embarsy's proxy so auth and Monitoring counters just work.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|