@bojanrajkovic/mcp-paprika 1.0.0 → 1.0.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.
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { EmbeddingClient } from "./embeddings.js";
|
|
1
|
+
import { EmbeddingClient, EMBEDDING_SCHEMA_VERSION } from "./embeddings.js";
|
|
2
2
|
import { VectorStore } from "./vector-store.js";
|
|
3
3
|
import { registerDiscoverTool } from "../tools/discover.js";
|
|
4
4
|
import { getCacheDir } from "../utils/xdg.js";
|
|
@@ -9,13 +9,16 @@ export async function setupDiscoverFeature(server, ctx, sync, config) {
|
|
|
9
9
|
return;
|
|
10
10
|
}
|
|
11
11
|
const embedder = new EmbeddingClient(embeddingsConfig);
|
|
12
|
-
const vectorStore = new VectorStore(getCacheDir(), embedder);
|
|
12
|
+
const vectorStore = new VectorStore(getCacheDir(), embedder, embeddingsConfig.model, EMBEDDING_SCHEMA_VERSION);
|
|
13
13
|
await vectorStore.init();
|
|
14
14
|
registerDiscoverTool(server, ctx, vectorStore);
|
|
15
15
|
// Cold-start initial indexing: the initial sync.syncOnce() in index.ts fires
|
|
16
|
-
// sync:complete BEFORE this subscription exists.
|
|
17
|
-
// vector
|
|
18
|
-
|
|
16
|
+
// sync:complete BEFORE this subscription exists. Re-index all recipes when
|
|
17
|
+
// the vector store is empty or significantly out of sync with the recipe
|
|
18
|
+
// store (e.g. stale test data, orphaned entries from a prior crash, or
|
|
19
|
+
// a model/dimension change that invalidated the old vectors).
|
|
20
|
+
if (ctx.store.size > 0 && vectorStore.size < ctx.store.size * 0.9) {
|
|
21
|
+
vectorStore.clearHashes();
|
|
19
22
|
await vectorStore.indexRecipes(ctx.store.getAll(), (uids) => ctx.store.resolveCategories(uids));
|
|
20
23
|
}
|
|
21
24
|
sync.events.on("sync:complete", async (result) => {
|
|
@@ -56,6 +56,14 @@ export declare class EmbeddingClient {
|
|
|
56
56
|
*/
|
|
57
57
|
embed(text: string): Promise<Array<number>>;
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Schema version for the embedding text format.
|
|
61
|
+
*
|
|
62
|
+
* Bump this whenever `recipeToEmbeddingText` changes (fields added/removed,
|
|
63
|
+
* format restructured) so that the vector store detects the change and
|
|
64
|
+
* triggers a full re-index on next startup.
|
|
65
|
+
*/
|
|
66
|
+
export declare const EMBEDDING_SCHEMA_VERSION = 1;
|
|
59
67
|
/**
|
|
60
68
|
* Convert a recipe to text suitable for embedding.
|
|
61
69
|
*
|
|
@@ -157,6 +157,14 @@ export class EmbeddingClient {
|
|
|
157
157
|
return first;
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Schema version for the embedding text format.
|
|
162
|
+
*
|
|
163
|
+
* Bump this whenever `recipeToEmbeddingText` changes (fields added/removed,
|
|
164
|
+
* format restructured) so that the vector store detects the change and
|
|
165
|
+
* triggers a full re-index on next startup.
|
|
166
|
+
*/
|
|
167
|
+
export const EMBEDDING_SCHEMA_VERSION = 1;
|
|
160
168
|
/**
|
|
161
169
|
* Convert a recipe to text suitable for embedding.
|
|
162
170
|
*
|
|
@@ -47,16 +47,27 @@ import type { Recipe, CategoryUid } from "../paprika/types.js";
|
|
|
47
47
|
export declare class VectorStore {
|
|
48
48
|
private readonly _vectorsDir;
|
|
49
49
|
private readonly _hashIndexPath;
|
|
50
|
+
private readonly _metaPath;
|
|
50
51
|
private readonly _index;
|
|
51
52
|
private readonly _embedder;
|
|
53
|
+
private readonly _modelId;
|
|
54
|
+
private readonly _schemaVersion;
|
|
52
55
|
private _hashes;
|
|
53
|
-
constructor(cacheDir: string, embedder: EmbeddingClient);
|
|
56
|
+
constructor(cacheDir: string, embedder: EmbeddingClient, modelId: string, schemaVersion: number);
|
|
54
57
|
init(): Promise<void>;
|
|
55
58
|
private _loadHashIndex;
|
|
59
|
+
private _loadMeta;
|
|
60
|
+
private _persistMeta;
|
|
56
61
|
private _backupFile;
|
|
57
62
|
indexRecipes(recipes: ReadonlyArray<Recipe>, resolveCats: (uids: ReadonlyArray<CategoryUid>) => ReadonlyArray<string>): Promise<IndexingResult>;
|
|
58
63
|
indexRecipe(recipe: Readonly<Recipe>, categoryNames: ReadonlyArray<string>): Promise<IndexingResult>;
|
|
59
64
|
get size(): number;
|
|
65
|
+
/**
|
|
66
|
+
* Reset the in-memory hash index so that the next `indexRecipes()` call
|
|
67
|
+
* re-embeds every recipe regardless of prior state. The stale on-disk
|
|
68
|
+
* hash file is overwritten once indexing persists the new hashes.
|
|
69
|
+
*/
|
|
70
|
+
clearHashes(): void;
|
|
60
71
|
private _persistHashes;
|
|
61
72
|
search(query: string, topK?: number): Promise<ReadonlyArray<SemanticResult>>;
|
|
62
73
|
removeRecipe(uid: string): Promise<void>;
|
|
@@ -39,17 +39,27 @@ function isNodeError(error) {
|
|
|
39
39
|
function log(msg) {
|
|
40
40
|
process.stderr.write(`[mcp-paprika:vectors] ${msg}\n`);
|
|
41
41
|
}
|
|
42
|
+
const VectorMetaSchema = z.object({
|
|
43
|
+
model: z.string(),
|
|
44
|
+
schemaVersion: z.number().int().optional(),
|
|
45
|
+
});
|
|
42
46
|
export class VectorStore {
|
|
43
47
|
_vectorsDir;
|
|
44
48
|
_hashIndexPath;
|
|
49
|
+
_metaPath;
|
|
45
50
|
_index;
|
|
46
51
|
_embedder;
|
|
52
|
+
_modelId;
|
|
53
|
+
_schemaVersion;
|
|
47
54
|
_hashes = {};
|
|
48
|
-
constructor(cacheDir, embedder) {
|
|
55
|
+
constructor(cacheDir, embedder, modelId, schemaVersion) {
|
|
49
56
|
this._vectorsDir = join(cacheDir, "vectors");
|
|
50
57
|
this._hashIndexPath = join(this._vectorsDir, "hash-index.json");
|
|
58
|
+
this._metaPath = join(this._vectorsDir, "vector-meta.json");
|
|
51
59
|
this._index = new LocalIndex(this._vectorsDir);
|
|
52
60
|
this._embedder = embedder;
|
|
61
|
+
this._modelId = modelId;
|
|
62
|
+
this._schemaVersion = schemaVersion;
|
|
53
63
|
}
|
|
54
64
|
async init() {
|
|
55
65
|
await mkdir(this._vectorsDir, { recursive: true });
|
|
@@ -70,6 +80,18 @@ export class VectorStore {
|
|
|
70
80
|
}
|
|
71
81
|
// Load hash map — follows DiskCache pattern (disk-cache.ts:60-88)
|
|
72
82
|
await this._loadHashIndex();
|
|
83
|
+
// Invalidate vectors when the embedding model or schema version changes.
|
|
84
|
+
const meta = await this._loadMeta();
|
|
85
|
+
if (meta !== null) {
|
|
86
|
+
if (meta.model !== this._modelId) {
|
|
87
|
+
log(`embedding model changed (${meta.model} → ${this._modelId}), clearing vector index`);
|
|
88
|
+
this._hashes = {};
|
|
89
|
+
}
|
|
90
|
+
else if ((meta.schemaVersion ?? 0) !== this._schemaVersion) {
|
|
91
|
+
log(`embedding schema version changed (${String(meta.schemaVersion ?? 0)} → ${String(this._schemaVersion)}), clearing vector index`);
|
|
92
|
+
this._hashes = {};
|
|
93
|
+
}
|
|
94
|
+
}
|
|
73
95
|
}
|
|
74
96
|
async _loadHashIndex() {
|
|
75
97
|
let raw;
|
|
@@ -102,6 +124,36 @@ export class VectorStore {
|
|
|
102
124
|
}
|
|
103
125
|
this._hashes = result.data;
|
|
104
126
|
}
|
|
127
|
+
async _loadMeta() {
|
|
128
|
+
let raw;
|
|
129
|
+
try {
|
|
130
|
+
raw = await readFile(this._metaPath, "utf-8");
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
return VectorMetaSchema.parse(JSON.parse(raw));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async _persistMeta() {
|
|
146
|
+
const tmpPath = join(this._vectorsDir, `.vector-meta-${Date.now().toString()}.tmp`);
|
|
147
|
+
const fh = await open(tmpPath, "w");
|
|
148
|
+
try {
|
|
149
|
+
await fh.writeFile(JSON.stringify({ model: this._modelId, schemaVersion: this._schemaVersion }));
|
|
150
|
+
await fh.sync();
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await fh.close();
|
|
154
|
+
}
|
|
155
|
+
await rename(tmpPath, this._metaPath);
|
|
156
|
+
}
|
|
105
157
|
async _backupFile(src, dest) {
|
|
106
158
|
try {
|
|
107
159
|
await rename(src, dest);
|
|
@@ -158,11 +210,12 @@ export class VectorStore {
|
|
|
158
210
|
cause: error instanceof Error ? error : undefined,
|
|
159
211
|
});
|
|
160
212
|
}
|
|
161
|
-
// Update hash map
|
|
213
|
+
// Update hash map and model metadata
|
|
162
214
|
for (const entry of toEmbed) {
|
|
163
215
|
this._hashes[entry.recipe.uid] = entry.hash;
|
|
164
216
|
}
|
|
165
217
|
await this._persistHashes();
|
|
218
|
+
await this._persistMeta();
|
|
166
219
|
return { indexed: toEmbed.length, skipped, total: recipes.length };
|
|
167
220
|
}
|
|
168
221
|
async indexRecipe(recipe, categoryNames) {
|
|
@@ -171,6 +224,14 @@ export class VectorStore {
|
|
|
171
224
|
get size() {
|
|
172
225
|
return Object.keys(this._hashes).length;
|
|
173
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Reset the in-memory hash index so that the next `indexRecipes()` call
|
|
229
|
+
* re-embeds every recipe regardless of prior state. The stale on-disk
|
|
230
|
+
* hash file is overwritten once indexing persists the new hashes.
|
|
231
|
+
*/
|
|
232
|
+
clearHashes() {
|
|
233
|
+
this._hashes = {};
|
|
234
|
+
}
|
|
174
235
|
async _persistHashes() {
|
|
175
236
|
const tmpPath = join(this._vectorsDir, `.hash-index-${Date.now().toString()}.tmp`);
|
|
176
237
|
const fh = await open(tmpPath, "w");
|
|
@@ -185,7 +246,7 @@ export class VectorStore {
|
|
|
185
246
|
}
|
|
186
247
|
async search(query, topK = 10) {
|
|
187
248
|
const vector = await this._embedder.embed(query);
|
|
188
|
-
const results = await this._index.queryItems(vector, topK);
|
|
249
|
+
const results = await this._index.queryItems(vector, query, topK);
|
|
189
250
|
return results.map((r) => ({
|
|
190
251
|
uid: r.item.id,
|
|
191
252
|
score: r.score,
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bojanrajkovic/mcp-paprika",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "MCP server for Paprika recipe manager",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/bojanrajkovic/mcp-paprika"
|
|
9
|
+
},
|
|
6
10
|
"bin": {
|
|
7
11
|
"mcp-paprika": "dist/index.js"
|
|
8
12
|
},
|