@context-vault/core 2.8.13 → 2.8.15
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/package.json +1 -1
- package/src/index/embed.js +45 -29
- package/src/index/index.js +10 -5
- package/src/retrieve/index.js +2 -2
- package/src/server/tools/delete-context.js +9 -2
- package/src/server/tools.js +6 -1
package/package.json
CHANGED
package/src/index/embed.js
CHANGED
|
@@ -14,38 +14,51 @@ let extractor = null;
|
|
|
14
14
|
/** @type {null | true | false} null = unknown, true = working, false = failed */
|
|
15
15
|
let embedAvailable = null;
|
|
16
16
|
|
|
17
|
+
/** Shared promise for in-flight initialization — prevents concurrent loads */
|
|
18
|
+
let loadingPromise = null;
|
|
19
|
+
|
|
17
20
|
async function ensurePipeline() {
|
|
18
21
|
if (embedAvailable === false) return null;
|
|
19
22
|
if (extractor) return extractor;
|
|
23
|
+
if (loadingPromise) return loadingPromise;
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
loadingPromise = (async () => {
|
|
26
|
+
try {
|
|
27
|
+
// Dynamic import — @huggingface/transformers is optional (its transitive
|
|
28
|
+
// dep `sharp` can fail to install on some platforms). When missing, the
|
|
29
|
+
// server still works with full-text search only.
|
|
30
|
+
const { pipeline, env } = await import("@huggingface/transformers");
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
// Redirect model cache to ~/.context-mcp/models/ so it works when the
|
|
33
|
+
// package is installed globally in a root-owned directory (e.g. /usr/lib/node_modules/).
|
|
34
|
+
const modelCacheDir = join(homedir(), ".context-mcp", "models");
|
|
35
|
+
mkdirSync(modelCacheDir, { recursive: true });
|
|
36
|
+
env.cacheDir = modelCacheDir;
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
console.error(
|
|
39
|
+
"[context-vault] Loading embedding model (first run may download ~22MB)...",
|
|
40
|
+
);
|
|
41
|
+
extractor = await pipeline(
|
|
42
|
+
"feature-extraction",
|
|
43
|
+
"Xenova/all-MiniLM-L6-v2",
|
|
44
|
+
);
|
|
45
|
+
embedAvailable = true;
|
|
46
|
+
return extractor;
|
|
47
|
+
} catch (e) {
|
|
48
|
+
embedAvailable = false;
|
|
49
|
+
console.error(
|
|
50
|
+
`[context-vault] Failed to load embedding model: ${e.message}`,
|
|
51
|
+
);
|
|
52
|
+
console.error(
|
|
53
|
+
`[context-vault] Semantic search disabled. Full-text search still works.`,
|
|
54
|
+
);
|
|
55
|
+
return null;
|
|
56
|
+
} finally {
|
|
57
|
+
loadingPromise = null;
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
return loadingPromise;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
export async function embed(text) {
|
|
@@ -57,6 +70,7 @@ export async function embed(text) {
|
|
|
57
70
|
if (!result?.data?.length) {
|
|
58
71
|
extractor = null;
|
|
59
72
|
embedAvailable = null;
|
|
73
|
+
loadingPromise = null;
|
|
60
74
|
throw new Error("Embedding pipeline returned empty result");
|
|
61
75
|
}
|
|
62
76
|
return new Float32Array(result.data);
|
|
@@ -76,6 +90,7 @@ export async function embedBatch(texts) {
|
|
|
76
90
|
if (!result?.data?.length) {
|
|
77
91
|
extractor = null;
|
|
78
92
|
embedAvailable = null;
|
|
93
|
+
loadingPromise = null;
|
|
79
94
|
throw new Error("Embedding pipeline returned empty result");
|
|
80
95
|
}
|
|
81
96
|
const dim = result.data.length / texts.length;
|
|
@@ -84,15 +99,16 @@ export async function embedBatch(texts) {
|
|
|
84
99
|
`Unexpected embedding dimension: ${result.data.length} / ${texts.length} = ${dim}`,
|
|
85
100
|
);
|
|
86
101
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
);
|
|
102
|
+
// subarray() creates a view into result.data's index-space, correctly
|
|
103
|
+
// accounting for any non-zero byteOffset on the source typed array.
|
|
104
|
+
return texts.map((_, i) => result.data.subarray(i * dim, (i + 1) * dim));
|
|
90
105
|
}
|
|
91
106
|
|
|
92
107
|
/** Force re-initialization on next embed call. */
|
|
93
108
|
export function resetEmbedPipeline() {
|
|
94
109
|
extractor = null;
|
|
95
110
|
embedAvailable = null;
|
|
111
|
+
loadingPromise = null;
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
/** Check if embedding is currently available. */
|
package/src/index/index.js
CHANGED
|
@@ -307,11 +307,16 @@ export async function reindex(ctx, opts = {}) {
|
|
|
307
307
|
created,
|
|
308
308
|
);
|
|
309
309
|
if (result.changes > 0) {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
.
|
|
313
|
-
|
|
314
|
-
|
|
310
|
+
const rowidResult = ctx.stmts.getRowid.get(id);
|
|
311
|
+
if (rowidResult?.rowid) {
|
|
312
|
+
const embeddingText = [parsed.title, parsed.body]
|
|
313
|
+
.filter(Boolean)
|
|
314
|
+
.join(" ");
|
|
315
|
+
pendingEmbeds.push({
|
|
316
|
+
rowid: rowidResult.rowid,
|
|
317
|
+
text: embeddingText,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
315
320
|
stats.added++;
|
|
316
321
|
} else {
|
|
317
322
|
stats.unchanged++;
|
package/src/retrieve/index.js
CHANGED
|
@@ -157,9 +157,9 @@ export async function hybridSearch(
|
|
|
157
157
|
: 15;
|
|
158
158
|
const vecRows = ctx.db
|
|
159
159
|
.prepare(
|
|
160
|
-
`SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT
|
|
160
|
+
`SELECT v.rowid, v.distance FROM vault_vec v WHERE embedding MATCH ? ORDER BY distance LIMIT ?`,
|
|
161
161
|
)
|
|
162
|
-
.all(queryVec);
|
|
162
|
+
.all(queryVec, vecLimit);
|
|
163
163
|
|
|
164
164
|
if (vecRows.length) {
|
|
165
165
|
// Batch hydration: single query instead of N+1
|
|
@@ -32,10 +32,16 @@ export async function handler({ id }, ctx, { ensureIndexed }) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// Delete file from disk first (source of truth)
|
|
35
|
+
let fileWarning = null;
|
|
35
36
|
if (entry.file_path) {
|
|
36
37
|
try {
|
|
37
38
|
unlinkSync(entry.file_path);
|
|
38
|
-
} catch {
|
|
39
|
+
} catch (e) {
|
|
40
|
+
// ENOENT = already gone — not an error worth surfacing
|
|
41
|
+
if (e.code !== "ENOENT") {
|
|
42
|
+
fileWarning = `file could not be removed from disk (${e.code}): ${entry.file_path}`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
// Delete vector embedding
|
|
@@ -49,5 +55,6 @@ export async function handler({ id }, ctx, { ensureIndexed }) {
|
|
|
49
55
|
// Delete DB row (FTS trigger handles FTS cleanup)
|
|
50
56
|
ctx.stmts.deleteEntry.run(id);
|
|
51
57
|
|
|
52
|
-
|
|
58
|
+
const msg = `Deleted ${entry.kind}: ${entry.title || "(untitled)"} [${id}]`;
|
|
59
|
+
return ok(fileWarning ? `${msg}\nWarning: ${fileWarning}` : msg);
|
|
53
60
|
}
|
package/src/server/tools.js
CHANGED
|
@@ -28,9 +28,11 @@ export function registerTools(server, ctx) {
|
|
|
28
28
|
return async (...args) => {
|
|
29
29
|
if (ctx.activeOps) ctx.activeOps.count++;
|
|
30
30
|
let timer;
|
|
31
|
+
let handlerPromise;
|
|
31
32
|
try {
|
|
33
|
+
handlerPromise = Promise.resolve(handler(...args));
|
|
32
34
|
return await Promise.race([
|
|
33
|
-
|
|
35
|
+
handlerPromise,
|
|
34
36
|
new Promise((_, reject) => {
|
|
35
37
|
timer = setTimeout(
|
|
36
38
|
() => reject(new Error("TOOL_TIMEOUT")),
|
|
@@ -40,6 +42,9 @@ export function registerTools(server, ctx) {
|
|
|
40
42
|
]);
|
|
41
43
|
} catch (e) {
|
|
42
44
|
if (e.message === "TOOL_TIMEOUT") {
|
|
45
|
+
// Suppress any late rejection from the still-running handler to
|
|
46
|
+
// prevent unhandled promise rejection warnings in the host process.
|
|
47
|
+
handlerPromise?.catch(() => {});
|
|
43
48
|
return err(
|
|
44
49
|
"Tool timed out after 60s. Try a simpler query or run `context-vault reindex` first.",
|
|
45
50
|
"TIMEOUT",
|