@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. On first-ever startup (empty
17
- // vector index), explicitly index all recipes already in the store.
18
- if (vectorStore.size === 0 && ctx.store.size > 0) {
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.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
  },