pantrybase 0.0.0-stage → 0.2.0

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/server.js ADDED
@@ -0,0 +1,915 @@
1
+ import {
2
+ ALL_CATEGORIES,
3
+ IMAGE_SIDES,
4
+ INGEST_STATUSES,
5
+ MODEL_MAX_PX,
6
+ SOURCE_KINDS,
7
+ addToPantry,
8
+ availableIngredients,
9
+ closeAllContexts,
10
+ finalizeAttach,
11
+ imageForModel,
12
+ importPaths,
13
+ listPantry,
14
+ log,
15
+ matchRecipes,
16
+ maybeScanInbox,
17
+ mergeImages,
18
+ nextBatch,
19
+ normalizeName,
20
+ normalizeRecipe,
21
+ openContext,
22
+ packageVersion,
23
+ planAttach,
24
+ prepareRecipe,
25
+ recipeFileExists,
26
+ recipeInputSchema,
27
+ reconcile,
28
+ removeFromPantry,
29
+ resolveItem,
30
+ resolveItems,
31
+ rotateItem,
32
+ scoreRecipes,
33
+ searchRecipes,
34
+ startUiServer,
35
+ viewItems,
36
+ writeRecipeFile
37
+ } from "./chunk-QUWSBZ2V.js";
38
+
39
+ // src/server.ts
40
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
41
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
42
+
43
+ // src/mcp/register.ts
44
+ import { z } from "zod";
45
+
46
+ // src/mcp/define-tool.ts
47
+ function defineTool(def) {
48
+ return def;
49
+ }
50
+ function text(value) {
51
+ const body = typeof value === "string" ? value : JSON.stringify(value);
52
+ return { content: [{ type: "text", text: body }] };
53
+ }
54
+ function errorResult(message) {
55
+ return { content: [{ type: "text", text: message }], isError: true };
56
+ }
57
+
58
+ // src/mcp/register.ts
59
+ var projectRootField = {
60
+ project_root: z.string().optional().describe("Project directory whose ./pantrybase data to use (default: the server's cwd)")
61
+ };
62
+ function registerTools(server, tools2) {
63
+ for (const tool of tools2) {
64
+ server.registerTool(
65
+ tool.name,
66
+ {
67
+ description: tool.description,
68
+ inputSchema: { ...tool.schema, ...projectRootField }
69
+ },
70
+ async (args) => {
71
+ const { project_root, ...rest } = args;
72
+ try {
73
+ const ctx = openContext(project_root);
74
+ ctx.embedder?.load().then(
75
+ () => ctx.embedQueue.kick(0),
76
+ () => {
77
+ }
78
+ );
79
+ ctx.sync();
80
+ await maybeScanInbox(ctx);
81
+ return await tool.handler(rest, ctx);
82
+ } catch (e) {
83
+ const msg = e instanceof Error ? e.message : String(e);
84
+ log(`${tool.name} failed:`, msg);
85
+ return errorResult(`${tool.name}: ${msg}`);
86
+ }
87
+ }
88
+ );
89
+ }
90
+ }
91
+
92
+ // src/mcp/tools/pb_get_recipe.ts
93
+ import { z as z2 } from "zod";
94
+ var pb_get_recipe_default = defineTool({
95
+ name: "pb_get_recipe",
96
+ description: "Fetch one recipe by slug. 'compact' (default) gives everything needed to cook or compare it \u2014 ingredients (raw + canonical), step text, tags, allergens, nutrition \u2014 without file metadata or per-step graph detail; 'full' returns the complete stored record.",
97
+ schema: {
98
+ slug: z2.string(),
99
+ format: z2.enum(["compact", "full"]).optional()
100
+ },
101
+ handler: ({ slug, format }, ctx) => {
102
+ const r = ctx.store.getRecipe(slug);
103
+ if (!r) return errorResult(`no recipe with slug "${slug}"`);
104
+ if (format === "full") return text(r);
105
+ const {
106
+ file_path: _f,
107
+ file_mtime: _m,
108
+ file_size: _z,
109
+ content_hash: _h,
110
+ id: _id,
111
+ created_at: _c,
112
+ updated_at: _u,
113
+ extraction,
114
+ ingredients,
115
+ steps,
116
+ ...rest
117
+ } = r;
118
+ return text({
119
+ ...rest,
120
+ ingredients: ingredients.map((i) => ({
121
+ raw: i.raw,
122
+ canonical: i.canonical,
123
+ prep: i.prep,
124
+ optional: i.optional,
125
+ source: i.source,
126
+ staple: i.staple
127
+ })),
128
+ steps: steps.map((s) => s.title ? `${s.title}: ${s.text}` : s.text),
129
+ needs_review: extraction?.needs_review || void 0,
130
+ uncertain: extraction?.uncertain?.length ? extraction.uncertain : void 0
131
+ });
132
+ }
133
+ });
134
+
135
+ // src/mcp/tools/pb_inbox_image.ts
136
+ import path from "path";
137
+ import { z as z3 } from "zod";
138
+ var pb_inbox_image_default = defineTool({
139
+ name: "pb_inbox_image",
140
+ description: "Show one queued (or archived) photo as an image, resized for reading (long edge \u2264 1600px by default; ask for up to 2400 if small print is unreadable). HEIC is converted automatically. If the text runs sideways or upside down, fix it with pb_inbox_rotate and look again.",
141
+ schema: {
142
+ id: z3.number().int(),
143
+ max_px: z3.number().int().min(400).max(2400).optional()
144
+ },
145
+ handler: async ({ id, max_px }, ctx) => {
146
+ const [item] = ctx.store.ingestByIds([id]);
147
+ if (!item) return errorResult(`no photo #${id}`);
148
+ const img = await imageForModel(path.join(ctx.paths.data, item.path), max_px ?? MODEL_MAX_PX);
149
+ return {
150
+ content: [
151
+ {
152
+ type: "text",
153
+ text: JSON.stringify({
154
+ id: item.id,
155
+ name: item.original_name,
156
+ status: item.status,
157
+ captured_at: item.captured_at,
158
+ shown: `${img.width}x${img.height}`
159
+ })
160
+ },
161
+ { type: "image", data: img.data.toString("base64"), mimeType: img.mimeType }
162
+ ]
163
+ };
164
+ }
165
+ });
166
+
167
+ // src/mcp/tools/pb_inbox_list.ts
168
+ import { z as z4 } from "zod";
169
+ var pb_inbox_list_default = defineTool({
170
+ name: "pb_inbox_list",
171
+ description: "Photo queue counts plus a page of photos (optionally by status: pending, done, skipped, failed), in capture order.",
172
+ schema: {
173
+ status: z4.enum(INGEST_STATUSES).optional(),
174
+ limit: z4.number().int().min(1).max(200).optional().describe("default 20"),
175
+ offset: z4.number().int().min(0).optional()
176
+ },
177
+ handler: ({ status, limit, offset }, ctx) => {
178
+ const items = ctx.store.listIngest({ status, limit: limit ?? 20, offset });
179
+ return text({
180
+ counts: ctx.store.ingestCounts(),
181
+ items: viewItems(items, ctx.store.allIngest())
182
+ });
183
+ }
184
+ });
185
+
186
+ // src/mcp/tools/pb_inbox_next.ts
187
+ import { z as z5 } from "zod";
188
+ var pb_inbox_next_default = defineTool({
189
+ name: "pb_inbox_next",
190
+ description: "The next pending inbox photos in capture order, plus queue progress. Cards are usually shot front then back (a back has no title), so look at the photos in order with pb_inbox_image, group the ones that belong to one card, and save it with pb_save_recipe {ingest:[{id, side}]}. `retake_of` flags a near-identical photo: attach it as an extra image or skip it with pb_inbox_resolve. `rotation_hint` flags a photo whose shape differs from the rest \u2014 rotate it with pb_inbox_rotate if it reads sideways.",
191
+ schema: {
192
+ window: z5.number().int().min(1).max(12).optional().describe("How many photos to return (default 4)")
193
+ },
194
+ handler: ({ window }, ctx) => {
195
+ const { progress, items } = nextBatch(ctx, window ?? 4);
196
+ if (items.length === 0) return text({ progress, items: [], done: true });
197
+ return text({ progress, items });
198
+ }
199
+ });
200
+
201
+ // src/mcp/tools/pb_inbox_resolve.ts
202
+ import { z as z6 } from "zod";
203
+ var pb_inbox_resolve_default = defineTool({
204
+ name: "pb_inbox_resolve",
205
+ description: "Set aside photos you won't save: 'skipped' (retake, not a recipe, duplicate of a saved card) or 'failed' (unreadable \u2014 say why); 'pending' puts them back in the queue. Files move to inbox/skipped/ or inbox/failed/ with the reason beside them, so the state survives rebuilds. Photos attached to a recipe can't be resolved.",
206
+ schema: {
207
+ ids: z6.array(z6.number().int()).min(1),
208
+ status: z6.enum(["skipped", "failed", "pending"]),
209
+ reason: z6.string().optional().describe('e.g. "retake of #12", "back of card too blurry"')
210
+ },
211
+ handler: ({ ids, status, reason }, ctx) => {
212
+ const r = resolveItems(ctx, ids, status, reason);
213
+ return text({ ...r, queue: ctx.store.ingestCounts() });
214
+ }
215
+ });
216
+
217
+ // src/mcp/tools/pb_inbox_rotate.ts
218
+ import { z as z7 } from "zod";
219
+ var pb_inbox_rotate_default = defineTool({
220
+ name: "pb_inbox_rotate",
221
+ description: "Turn a photo upright when its text runs sideways or upside down (camera orientation flags are already applied, so this is for photos whose pixels are rotated). Degrees are clockwise: text reading bottom-to-top needs 90, upside down needs 180, top-to-bottom needs 270. The file is fixed on disk (HEIC becomes JPEG), so the archive, thumbnails and retake detection all see the upright photo. Call before pb_inbox_image when pb_inbox_next gives a rotation_hint, or after a photo shows up sideways; view it again to confirm.",
222
+ schema: {
223
+ id: z7.number().int(),
224
+ degrees: z7.union([z7.literal(90), z7.literal(180), z7.literal(270)])
225
+ },
226
+ handler: async ({ id, degrees }, ctx) => text({ rotated: degrees, item: await rotateItem(ctx, id, degrees) })
227
+ });
228
+
229
+ // src/mcp/tools/pb_ingest_source.ts
230
+ import { z as z8 } from "zod";
231
+ var pb_ingest_source_default = defineTool({
232
+ name: "pb_ingest_source",
233
+ description: "Copy recipe photos (JPEG, PNG, WebP, HEIC, TIFF) from files or folders into pantrybase/inbox/ for extraction. Originals are never moved or changed; photos already known (same bytes) are skipped. Folders are searched recursively by default. Returns counts and the queue state; then work through it with pb_inbox_next.",
234
+ schema: {
235
+ paths: z8.array(z8.string()).min(1).describe("Files or folders; relative to the project, absolute, or ~/\u2026"),
236
+ recursive: z8.boolean().optional()
237
+ },
238
+ handler: async ({ paths, recursive }, ctx) => {
239
+ const r = await importPaths(ctx, paths, { recursive });
240
+ return text({
241
+ copied: r.copied,
242
+ already_known: r.already_known,
243
+ unsupported_files: r.unsupported,
244
+ missing: r.missing.length ? r.missing : void 0,
245
+ duplicates_parked: r.scan.duplicates.length || void 0,
246
+ unreadable: r.scan.errors.length ? r.scan.errors : void 0,
247
+ queue: ctx.store.ingestCounts()
248
+ });
249
+ }
250
+ });
251
+
252
+ // src/mcp/tools/pb_list_tags.ts
253
+ import { z as z9 } from "zod";
254
+ var pb_list_tags_default = defineTool({
255
+ name: "pb_list_tags",
256
+ description: "Tags and facets in use, with recipe counts, grouped by category. With vocabulary:true also returns the allowed vocabulary (closed categories list every value). Call once before a batch of saves and reuse existing values.",
257
+ schema: {
258
+ category: z9.enum(ALL_CATEGORIES).optional(),
259
+ vocabulary: z9.boolean().optional()
260
+ },
261
+ handler: ({ category, vocabulary }, ctx) => {
262
+ const used = {};
263
+ for (const t of ctx.store.listTags(category)) {
264
+ const bucket = used[t.category] ?? {};
265
+ bucket[t.name] = t.count;
266
+ used[t.category] = bucket;
267
+ }
268
+ if (!vocabulary) return text(used);
269
+ const vocab = ctx.vocab.summary();
270
+ return text({
271
+ used,
272
+ vocabulary: category ? { [category]: vocab[category] } : vocab
273
+ });
274
+ }
275
+ });
276
+
277
+ // src/mcp/tools/pb_merge_ingredients.ts
278
+ import { z as z10 } from "zod";
279
+
280
+ // src/ingredients/merge.ts
281
+ import fs from "fs";
282
+ import { isMap, isSeq, parseDocument, YAMLMap, YAMLSeq } from "yaml";
283
+ function toRecipe(r) {
284
+ const {
285
+ id: _id,
286
+ created_at: _c,
287
+ updated_at: _u,
288
+ file_path: _f,
289
+ file_mtime: _m,
290
+ file_size: _s,
291
+ content_hash: _h,
292
+ ...recipe
293
+ } = r;
294
+ return recipe;
295
+ }
296
+ function renameIn(recipe, from, into) {
297
+ let changed = false;
298
+ const swap = (name) => {
299
+ if (name === from) {
300
+ changed = true;
301
+ return into;
302
+ }
303
+ return name;
304
+ };
305
+ for (const ing of recipe.ingredients) {
306
+ ing.canonical = swap(ing.canonical);
307
+ if (ing.alternatives) ing.alternatives = ing.alternatives.map(swap);
308
+ for (const c of ing.components ?? []) c.canonical = swap(c.canonical);
309
+ }
310
+ for (const step of recipe.steps) if (step.uses) step.uses = [...new Set(step.uses.map(swap))];
311
+ return changed;
312
+ }
313
+ function persistAlias(file, from, into) {
314
+ const doc = parseDocument(fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "{}\n");
315
+ const root = doc.contents;
316
+ if (!isMap(root)) return;
317
+ const map = root;
318
+ const scalar = (n) => String(n?.value ?? n);
319
+ const carried = [];
320
+ const old = map.get(from, true);
321
+ if (isMap(old)) {
322
+ const aliases2 = old.get("aliases", true);
323
+ if (isSeq(aliases2))
324
+ for (const a of aliases2.items) carried.push(scalar(a));
325
+ }
326
+ if (map.has(from)) map.delete(from);
327
+ let target = map.get(into, true);
328
+ if (!isMap(target)) {
329
+ const fresh = new YAMLMap();
330
+ fresh.flow = true;
331
+ map.set(into, fresh);
332
+ target = fresh;
333
+ }
334
+ const entry = target;
335
+ let aliases = entry.get("aliases", true);
336
+ if (!isSeq(aliases)) {
337
+ const fresh = new YAMLSeq();
338
+ fresh.flow = true;
339
+ entry.set("aliases", fresh);
340
+ aliases = fresh;
341
+ }
342
+ const seq = aliases;
343
+ const existing = new Set(seq.items.map(scalar));
344
+ for (const a of [from, ...carried]) if (!existing.has(a)) seq.add(a);
345
+ fs.writeFileSync(file, doc.toString({ lineWidth: 0, flowCollectionPadding: false }));
346
+ }
347
+ function mergeIngredients(ctx, fromName, intoName) {
348
+ const from = ctx.store.getIngredient(normalizeName(fromName));
349
+ if (!from) throw new Error(`unknown ingredient "${fromName}"`);
350
+ const into = resolveItem(ctx, intoName, true)?.row;
351
+ if (!into) throw new Error(`cannot resolve "${intoName}"`);
352
+ if (from.id === into.id) throw new Error(`"${fromName}" is already "${into.canonical}"`);
353
+ const pantryMoved = ctx.store.pantryList().some((p) => p.ingredient_id === from.id);
354
+ const updated = [];
355
+ for (const slug of ctx.store.recipesUsingIngredient(from.id)) {
356
+ const rec = ctx.store.getRecipe(slug);
357
+ if (!rec) continue;
358
+ const recipe = toRecipe(rec);
359
+ if (renameIn(recipe, from.canonical, into.canonical)) {
360
+ writeRecipeFile(ctx.paths, recipe);
361
+ updated.push(slug);
362
+ }
363
+ }
364
+ ctx.store.mergeIngredientRows(from.id, into.id);
365
+ ctx.store.addAlias(into.id, from.canonical);
366
+ persistAlias(ctx.paths.ingredientsYaml, from.canonical, into.canonical);
367
+ ctx.refresh();
368
+ reconcile(ctx);
369
+ return {
370
+ from: from.canonical,
371
+ into: into.canonical,
372
+ recipes_updated: updated,
373
+ pantry_moved: pantryMoved
374
+ };
375
+ }
376
+
377
+ // src/mcp/tools/pb_merge_ingredients.ts
378
+ var pb_merge_ingredients_default = defineTool({
379
+ name: "pb_merge_ingredients",
380
+ description: "Merge an ingredient into another when they are the same thing (e.g. 'pitted dates' into 'dates'). Rewrites every recipe file that uses it, moves aliases and the pantry entry, and records the alias in ingredients.yaml so future saves resolve to the target. Check pb_save_recipe's new_ingredients after a batch for candidates.",
381
+ schema: {
382
+ from: z10.string().min(1),
383
+ into: z10.string().min(1)
384
+ },
385
+ handler: ({ from, into }, ctx) => text(mergeIngredients(ctx, from, into))
386
+ });
387
+
388
+ // src/mcp/tools/pb_open_ui.ts
389
+ var pb_open_ui_default = defineTool({
390
+ name: "pb_open_ui",
391
+ description: "Start (or find) the local pantrybase web UI for this project and return its URL \u2014 search with facets, recipe pages, the pantry and 'what can I cook', photo upload and the inbox queue, and the knowledge graph explorer. Give the user the URL; it is served on 127.0.0.1 only.",
392
+ schema: {},
393
+ handler: async (_args, ctx) => {
394
+ const ui = await startUiServer(ctx, { version: packageVersion() });
395
+ return text({ url: ui.url, data_dir: ctx.paths.data });
396
+ }
397
+ });
398
+
399
+ // src/mcp/tools/pb_pantry.ts
400
+ import { z as z11 } from "zod";
401
+ var pb_pantry_list = defineTool({
402
+ name: "pb_pantry_list",
403
+ description: "What's in the pantry: canonical ingredient names with optional quantity, unit and note.",
404
+ schema: {},
405
+ handler: (_args, ctx) => {
406
+ const items = listPantry(ctx);
407
+ return text({
408
+ count: items.length,
409
+ items: items.map((p) => ({
410
+ name: p.canonical,
411
+ qty: p.qty ?? void 0,
412
+ unit: p.unit ?? void 0,
413
+ note: p.note ?? void 0
414
+ }))
415
+ });
416
+ }
417
+ });
418
+ var pb_pantry_add = defineTool({
419
+ name: "pb_pantry_add",
420
+ description: "Add items to the pantry (or update their quantity). Names are matched to the ingredient catalog (aliases, plurals and regional names: 'eggs', 'baby marrow', 'spring onions'); unknown names become new ingredients.",
421
+ schema: {
422
+ items: z11.array(
423
+ z11.object({
424
+ name: z11.string().min(1),
425
+ qty: z11.number().nullable().optional(),
426
+ unit: z11.string().nullable().optional(),
427
+ note: z11.string().nullable().optional()
428
+ })
429
+ ).min(1)
430
+ },
431
+ handler: ({ items }, ctx) => text({ added: addToPantry(ctx, items), total: listPantry(ctx).length })
432
+ });
433
+ var pb_pantry_remove = defineTool({
434
+ name: "pb_pantry_remove",
435
+ description: "Remove items from the pantry by name, or everything with all:true.",
436
+ schema: {
437
+ names: z11.array(z11.string()).optional(),
438
+ all: z11.boolean().optional()
439
+ },
440
+ handler: ({ names, all }, ctx) => {
441
+ if (all) return text({ cleared: ctx.store.pantryClear() });
442
+ return text({ ...removeFromPantry(ctx, names ?? []), total: listPantry(ctx).length });
443
+ }
444
+ });
445
+
446
+ // src/mcp/tools/pb_pantry_match.ts
447
+ import { z as z12 } from "zod";
448
+ var pb_pantry_match_default = defineTool({
449
+ name: "pb_pantry_match",
450
+ description: "What can I cook? Ranks recipes by how much of their required ingredients the pantry covers (staples like salt, oil and 'from your kitchen' items, and optional items, never count against a recipe; printed alternatives and the parts of composite ingredients do count). Add `ingredients` for things you have right now; use_pantry:false to match only those. Returns coverage, what's missing, and near misses (1\u20132 items short).",
451
+ schema: {
452
+ ingredients: z12.array(z12.string()).optional(),
453
+ use_pantry: z12.boolean().optional().describe("default true"),
454
+ min_coverage: z12.number().min(0).max(1).optional().describe("default 0.5"),
455
+ max_missing: z12.number().int().min(0).optional(),
456
+ limit: z12.number().int().min(1).max(50).optional().describe("default 10"),
457
+ tags: z12.partialRecord(z12.enum(ALL_CATEGORIES), z12.array(z12.string())).optional(),
458
+ exclude_allergens: z12.array(z12.string()).optional(),
459
+ max_total_min: z12.number().optional()
460
+ },
461
+ handler: ({ ingredients, use_pantry, min_coverage, max_missing, limit, ...filters }, ctx) => {
462
+ const r = matchRecipes(ctx, {
463
+ ingredients,
464
+ use_pantry,
465
+ min_coverage: min_coverage ?? 0.5,
466
+ max_missing,
467
+ limit,
468
+ filters
469
+ });
470
+ return text({
471
+ available_ingredients: r.available,
472
+ unknown_ingredients: r.unknown_ingredients.length ? r.unknown_ingredients : void 0,
473
+ results: r.results.map((m) => ({
474
+ slug: m.slug,
475
+ title: m.title,
476
+ coverage: m.coverage,
477
+ have: `${m.have}/${m.need}`,
478
+ missing: m.missing.map((x) => x.canonical),
479
+ near_miss: m.near_miss || void 0
480
+ }))
481
+ });
482
+ }
483
+ });
484
+
485
+ // src/mcp/tools/pb_providers.ts
486
+ import { z as z13 } from "zod";
487
+ var pb_providers_default = defineTool({
488
+ name: "pb_providers",
489
+ description: "Meal-box provider profiles. Without `id`: list them (id, name, how to recognise a card). With `id`: the extraction guide for that provider's cards plus the tag vocabulary \u2014 read it once before extracting a batch of cards.",
490
+ schema: {
491
+ id: z13.string().optional().describe('e.g. "ucook"; "generic" for anything else')
492
+ },
493
+ handler: ({ id }, ctx) => {
494
+ if (!id) {
495
+ return text(
496
+ ctx.providers.list().map((p2) => ({
497
+ id: p2.id,
498
+ name: p2.name,
499
+ country: p2.country,
500
+ recognise: p2.recognise
501
+ }))
502
+ );
503
+ }
504
+ const p = ctx.providers.get(id);
505
+ if (!p)
506
+ return errorResult(
507
+ `no provider "${id}"; known: ${ctx.providers.list().map((x) => x.id).join(", ")}`
508
+ );
509
+ const summary = ctx.vocab.summary(25);
510
+ return text(
511
+ [
512
+ p.guide.trim(),
513
+ "",
514
+ "## Server-side rules (applied automatically on save)",
515
+ `- Plan labels \u2192 tags: ${JSON.stringify(p.plan_labels)}`,
516
+ `- Brands stripped from canonical names: ${p.brands.join(", ") || "none"}`,
517
+ `- Kitchen items treated as equipment: ${Object.keys(p.equipment_terms).join(", ") || "none"}`,
518
+ `- Nutrition basis default: ${p.nutrition_basis}; default servings: ${p.default_servings ?? "none"}`,
519
+ `- Upright card shape: ${p.orientation ?? "varies"} (photos that don't match are flagged with rotation_hint; fix them with pb_inbox_rotate)`,
520
+ "",
521
+ "## Tag vocabulary (closed = only these values; open = top values, new ones allowed)",
522
+ ...Object.entries(summary).map(
523
+ ([cat, v]) => `- ${cat} (${v.mode}): ${v.values.join(", ")}`
524
+ )
525
+ ].join("\n")
526
+ );
527
+ }
528
+ });
529
+
530
+ // src/mcp/tools/pb_rebuild.ts
531
+ import { z as z14 } from "zod";
532
+ var pb_rebuild_default = defineTool({
533
+ name: "pb_rebuild",
534
+ description: "Re-sync the index from pantrybase/recipes/*.md (markdown is the source of truth), reloading ingredients.yaml, vocabulary.yaml and provider profiles. Default is incremental (mtime/hash); full:true re-parses every file (use after editing the vocabulary or catalog); reembed:true also recomputes all embeddings.",
535
+ schema: {
536
+ full: z14.boolean().optional(),
537
+ reembed: z14.boolean().optional()
538
+ },
539
+ handler: ({ full, reembed }, ctx) => {
540
+ const reloaded = ctx.refresh(true);
541
+ const result = reconcile(ctx, { full: full ?? false, reembed: reembed ?? false });
542
+ return text({ reloaded, ...result });
543
+ }
544
+ });
545
+
546
+ // src/mcp/tools/pb_save_recipe.ts
547
+ import { z as z15 } from "zod";
548
+
549
+ // src/files/slug.ts
550
+ var MAX_SLUG = 60;
551
+ function slugify(title) {
552
+ const base = title.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/['’]/g, "").replace(/&/g, " and ").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
553
+ let slug = base.slice(0, MAX_SLUG).replace(/-+$/g, "");
554
+ if (!slug) slug = "recipe";
555
+ return slug;
556
+ }
557
+ function uniqueSlug(base, exists) {
558
+ if (!exists(base)) return base;
559
+ for (let n = 2; n < 1e3; n++) {
560
+ const candidate = `${base}-${n}`;
561
+ if (!exists(candidate)) return candidate;
562
+ }
563
+ throw new Error(`could not find a free slug for ${base}`);
564
+ }
565
+
566
+ // src/recipes/service.ts
567
+ function saveRecipe(ctx, input, opts = {}) {
568
+ const ingest = opts.ingest ?? [];
569
+ if (ingest.length) {
570
+ const items = ctx.store.ingestByIds(ingest.map((i) => i.id));
571
+ const slugs = new Set(items.map((i) => i.recipe_slug));
572
+ const first = items[0];
573
+ const owner = first?.recipe_slug ? ctx.store.getRecipe(first.recipe_slug) : void 0;
574
+ const sameRecipe = owner !== void 0 && (input.slug === owner.slug || normalizeName(input.title) === normalizeName(owner.title));
575
+ if (items.length === ingest.length && items.every((i) => i.status === "done") && slugs.size === 1 && owner && sameRecipe && // An update that changes the recipe is a real save, not a retry.
576
+ !input.slug) {
577
+ return { saved: false, already_ingested: true, slug: owner.slug };
578
+ }
579
+ }
580
+ const existing = input.slug ? ctx.store.getRecipe(input.slug) : void 0;
581
+ if (!existing && !opts.allow_duplicate) {
582
+ const dups = ctx.store.findDuplicates(
583
+ input.title,
584
+ input.ingredients.map((i) => normalizeName(i.canonical))
585
+ );
586
+ if (dups.length > 0) return { saved: false, duplicate_of: dups };
587
+ }
588
+ const slug = existing ? existing.slug : uniqueSlug(
589
+ input.slug ?? slugify(input.title),
590
+ (s) => ctx.store.getRecipe(s) !== void 0 || recipeFileExists(ctx.paths, s)
591
+ );
592
+ const plan = planAttach(ctx, slug, ingest);
593
+ if (plan.errors.length) return { saved: false, error: plan.errors.join("; ") };
594
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
595
+ const source = input.source ?? existing?.source ?? { kind: "manual" };
596
+ const recipe = normalizeRecipe(
597
+ {
598
+ ...input,
599
+ source: { ...source, ingested: source.ingested ?? existing?.source.ingested ?? today }
600
+ },
601
+ slug
602
+ );
603
+ if (plan.targets.length || existing) {
604
+ const merged = mergeImages(
605
+ {
606
+ images: [...existing?.images ?? [], ...recipe.images],
607
+ extraction: existing?.extraction
608
+ },
609
+ plan.targets,
610
+ recipe.extraction?.images
611
+ );
612
+ recipe.images = merged.images;
613
+ if (merged.extraction.length) {
614
+ recipe.extraction = { ...recipe.extraction ?? {}, images: merged.extraction };
615
+ }
616
+ }
617
+ const prepared = prepareRecipe(ctx, recipe);
618
+ const file = writeRecipeFile(ctx.paths, prepared.recipe);
619
+ ctx.store.upsertRecipe(prepared.recipe, file, prepared.ingredientIds);
620
+ finalizeAttach(ctx, slug, plan.targets);
621
+ ctx.embedQueue.kick();
622
+ const { provider, ...vocabulary } = prepared.report;
623
+ const unknownDerived = prepared.recipe.derived_from.filter(
624
+ (s) => s !== slug && !ctx.store.getRecipe(s)
625
+ );
626
+ return {
627
+ ...unknownDerived.length ? { unknown_derived_from: unknownDerived } : {},
628
+ saved: true,
629
+ slug,
630
+ file: file.file_path,
631
+ updated: existing !== void 0,
632
+ new_ingredients: prepared.resolution.created,
633
+ aliased: prepared.resolution.aliased.map(({ from, to }) => ({ from, to })),
634
+ vocabulary,
635
+ provider_rules: provider,
636
+ unmatched_uses: prepared.unmatched_uses,
637
+ needs_review: prepared.recipe.extraction?.needs_review ?? false,
638
+ images: prepared.recipe.images
639
+ };
640
+ }
641
+
642
+ // src/mcp/tools/pb_save_recipe.ts
643
+ var pb_save_recipe_default = defineTool({
644
+ name: "pb_save_recipe",
645
+ description: "Save a recipe (new or, with `slug`, an update). Writes pantrybase/recipes/<slug>.md as the source of truth and indexes it. For meal-box cards read pb_providers {id} first and follow its guide; set provider.id so its rules apply. Pass `ingest` with every inbox photo of the card ({id, side}) \u2014 they are archived to images/<slug>/ and marked done; re-sending already-attached photos is a safe no-op. Ingredients need `raw` (as printed) and `canonical` (generic singular name, no brand) plus source box|kitchen. Steps are objects {title, text, techniques, uses, equipment, temp_c, minutes} (plain strings are accepted). Tags by category (protein, diet, starch, flavor, texture, cuisine, dish, meal, method, time, season, other) are normalised against the vocabulary. Write a `blurb` in your own words for semantic search and record unreadable fields in extraction.uncertain. Returns {saved, slug, \u2026} or {saved:false, duplicate_of:[\u2026]} \u2014 if it is a different recipe pass allow_duplicate:true; if it is the same card, attach the photos to that slug or skip them.",
646
+ schema: {
647
+ ...recipeInputSchema.shape,
648
+ ingest: z15.array(z15.object({ id: z15.number().int(), side: z15.enum(IMAGE_SIDES) })).optional().describe(
649
+ "Inbox photos of this card, e.g. [{id: 12, side: 'front'}, {id: 13, side: 'back'}]"
650
+ ),
651
+ allow_duplicate: z15.boolean().optional()
652
+ },
653
+ handler: ({ allow_duplicate, ingest, ...input }, ctx) => {
654
+ return text(saveRecipe(ctx, input, { allow_duplicate, ingest }));
655
+ }
656
+ });
657
+
658
+ // src/mcp/tools/pb_search.ts
659
+ import { z as z16 } from "zod";
660
+ var pb_search_default = defineTool({
661
+ name: "pb_search",
662
+ description: "Search recipes: hybrid keyword + semantic ranking (RRF) over title, blurb, ingredients and method, with filters. Omit `query` to just filter/browse. Tag filters accept any category \u2014 assigned tags (cuisine, diet, dish, protein, flavor\u2026) and derived facets (allergen, equipment, technique, chef, provider, plan); parents expand (cuisine:asian matches thai). Returns compact summaries; use pb_get_recipe for details.",
663
+ schema: {
664
+ query: z16.string().optional().describe("Free text: dish, ingredient, technique, mood, occasion\u2026"),
665
+ limit: z16.number().int().min(1).max(100).optional().describe("default 10"),
666
+ tags: z16.partialRecord(z16.enum(ALL_CATEGORIES), z16.array(z16.string())).optional().describe("e.g. {cuisine:['thai'], diet:['vegetarian']} \u2014 OR within a category, AND across"),
667
+ exclude_allergens: z16.array(z16.string()).optional().describe('e.g. ["dairy", "tree-nuts"]'),
668
+ exclude_ingredients: z16.array(z16.string()).optional(),
669
+ max_total_min: z16.number().optional(),
670
+ max_active_min: z16.number().optional().describe("Hands-on minutes"),
671
+ provider: z16.string().optional(),
672
+ source_kind: z16.enum(SOURCE_KINDS).optional(),
673
+ needs_review: z16.boolean().optional().describe("true = only recipes flagged for review"),
674
+ mode: z16.enum(["hybrid", "lexical", "semantic"]).optional()
675
+ },
676
+ handler: async ({ query, limit, mode, ...filters }, ctx) => {
677
+ const res = await searchRecipes(ctx, { query, limit, mode, filters });
678
+ return text({
679
+ count: res.results.length,
680
+ vector_used: res.vector_used,
681
+ results: res.results.map((r) => ({
682
+ slug: r.slug,
683
+ title: r.title,
684
+ subtitle: r.subtitle,
685
+ blurb: r.blurb,
686
+ active_min: r.active_min,
687
+ total_min: r.total_min,
688
+ tags: r.tags,
689
+ allergens: r.allergens.length ? r.allergens : void 0,
690
+ needs_review: r.needs_review || void 0,
691
+ score: r.score,
692
+ matched: r.matched
693
+ }))
694
+ });
695
+ }
696
+ });
697
+
698
+ // src/mcp/tools/pb_stats.ts
699
+ var pb_stats_default = defineTool({
700
+ name: "pb_stats",
701
+ description: "Counts of recipes, ingredients, tags, indexed chunks and vectors, plus data paths and model state.",
702
+ schema: {},
703
+ handler: (_args, ctx) => {
704
+ return text({
705
+ ...ctx.store.stats(),
706
+ inbox: ctx.store.ingestCounts(),
707
+ providers: ctx.providers.list().map((p) => p.id),
708
+ data_dir: ctx.paths.data,
709
+ embeddings: ctx.embedder ? ctx.embedder.ready ? "ready" : "not loaded" : "disabled"
710
+ });
711
+ }
712
+ });
713
+
714
+ // src/mcp/tools/pb_synthesis_context.ts
715
+ import { z as z17 } from "zod";
716
+
717
+ // src/synthesis/context.ts
718
+ var STEP_CHARS = 220;
719
+ function qtyText(i) {
720
+ const amount = [i.qty, i.unit].filter((x) => x !== void 0 && x !== null && x !== "").join(" ");
721
+ return `${amount ? `${amount} ` : ""}${i.canonical}${i.prep ? ` (${i.prep})` : ""}${i.optional ? " [optional]" : ""}${i.staple ? " [staple]" : ""}`;
722
+ }
723
+ function compactRecipe(r) {
724
+ return {
725
+ slug: r.slug,
726
+ title: r.title,
727
+ subtitle: r.subtitle,
728
+ blurb: r.blurb,
729
+ serves: r.servings ?? void 0,
730
+ minutes: r.time.total ?? void 0,
731
+ tags: {
732
+ cuisine: r.tags.cuisine,
733
+ dish: r.tags.dish,
734
+ flavor: r.tags.flavor,
735
+ texture: r.tags.texture,
736
+ protein: r.tags.protein,
737
+ diet: r.tags.diet
738
+ },
739
+ ingredients: r.ingredients.map(qtyText),
740
+ steps: r.steps.map((s) => {
741
+ const text2 = s.text.length > STEP_CHARS ? `${s.text.slice(0, STEP_CHARS - 1)}\u2026` : s.text;
742
+ const tech = s.techniques?.length ? ` [${s.techniques.join(", ")}]` : "";
743
+ return `${text2}${tech}`;
744
+ }),
745
+ tips: r.tips.length ? r.tips : void 0
746
+ };
747
+ }
748
+ function top(counts, n, min = 1) {
749
+ return [...counts.entries()].filter(([, c]) => c >= min).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, n);
750
+ }
751
+ function bump(map, key) {
752
+ if (key) map.set(key, (map.get(key) ?? 0) + 1);
753
+ }
754
+ async function synthesisContext(ctx, opts) {
755
+ const n = Math.min(Math.max(opts.n ?? 5, 1), 12);
756
+ const picked = [];
757
+ const how = [];
758
+ for (const s of opts.slugs ?? [])
759
+ if (ctx.store.getRecipe(s) && !picked.includes(s)) picked.push(s);
760
+ if (opts.slugs?.length) how.push("given slugs");
761
+ if (picked.length < n && opts.query) {
762
+ const res = await searchRecipes(ctx, {
763
+ query: opts.query,
764
+ filters: opts.filters,
765
+ limit: n * 3
766
+ });
767
+ let candidates = res.results.map((r) => r.slug);
768
+ if (opts.use_pantry || opts.ingredients?.length) {
769
+ const { ids } = availableIngredients(ctx, opts);
770
+ const scores = new Map(
771
+ scoreRecipes(
772
+ ctx,
773
+ ids,
774
+ res.results.map((r) => r.id)
775
+ ).map((m) => [m.slug, m.coverage])
776
+ );
777
+ candidates = candidates.map((slug, rank) => ({ slug, score: 1 / (rank + 1) + (scores.get(slug) ?? 0) })).sort((a, b) => b.score - a.score).map((c) => c.slug);
778
+ how.push("search re-ranked by pantry coverage");
779
+ } else {
780
+ how.push(`search${res.vector_used ? " (hybrid)" : " (keyword)"}`);
781
+ }
782
+ for (const s of candidates) if (picked.length < n && !picked.includes(s)) picked.push(s);
783
+ } else if (picked.length < n && (opts.use_pantry || opts.ingredients?.length)) {
784
+ for (const m of matchRecipes(ctx, { ...opts, limit: n * 2 }).results) {
785
+ if (picked.length < n && !picked.includes(m.slug)) picked.push(m.slug);
786
+ }
787
+ how.push("pantry match");
788
+ } else if (picked.length < n && !opts.slugs?.length) {
789
+ for (const r of (await searchRecipes(ctx, { filters: opts.filters, limit: n })).results) {
790
+ if (picked.length < n && !picked.includes(r.slug)) picked.push(r.slug);
791
+ }
792
+ how.push("filters");
793
+ }
794
+ const recipes = picked.map((s) => ctx.store.getRecipe(s)).filter((r) => !!r);
795
+ const ingredientCounts = /* @__PURE__ */ new Map();
796
+ const techniqueCounts = /* @__PURE__ */ new Map();
797
+ const cuisineCounts = /* @__PURE__ */ new Map();
798
+ const flavorCounts = /* @__PURE__ */ new Map();
799
+ const pairs = /* @__PURE__ */ new Map();
800
+ for (const r of recipes) {
801
+ const core = [
802
+ ...new Set(r.ingredients.filter((i) => !i.staple).map((i) => i.canonical))
803
+ ].sort();
804
+ for (const c of core) bump(ingredientCounts, c);
805
+ for (let i = 0; i < core.length; i++) {
806
+ for (let j = i + 1; j < core.length; j++) bump(pairs, `${core[i]} + ${core[j]}`);
807
+ }
808
+ for (const t of new Set(r.steps.flatMap((s) => s.techniques ?? []))) bump(techniqueCounts, t);
809
+ for (const c of r.tags.cuisine ?? []) bump(cuisineCounts, c);
810
+ for (const f of r.tags.flavor ?? []) bump(flavorCounts, f);
811
+ }
812
+ let pantry;
813
+ if (opts.use_pantry || opts.ingredients?.length) {
814
+ const { ids, unknown } = availableIngredients(ctx, opts);
815
+ const haveNames = new Set(
816
+ [...ingredientCounts.keys()].filter((name) => {
817
+ const row = ctx.store.getIngredient(name);
818
+ return row ? ids.has(row.id) : false;
819
+ })
820
+ );
821
+ pantry = {
822
+ have: [...haveNames].sort(),
823
+ missing_common: top(ingredientCounts, 30).map(([name]) => name).filter((name) => !haveNames.has(name)).slice(0, 12),
824
+ unknown
825
+ };
826
+ }
827
+ return {
828
+ selected_by: how.join(", ") || "none",
829
+ recipes: recipes.map(compactRecipe),
830
+ patterns: {
831
+ common_ingredients: top(ingredientCounts, 15, recipes.length > 2 ? 2 : 1).map(
832
+ ([k, v]) => `${k} \xD7${v}`
833
+ ),
834
+ common_pairings: top(pairs, 10, 2).map(([k, v]) => `${k} \xD7${v}`),
835
+ techniques: top(techniqueCounts, 10).map(([k, v]) => `${k} \xD7${v}`),
836
+ cuisines: top(cuisineCounts, 6).map(([k]) => k),
837
+ flavors: top(flavorCounts, 8).map(([k]) => k)
838
+ },
839
+ pantry,
840
+ to_save: `Write a new recipe drawing on these (don't copy one). Save it with pb_save_recipe: source {kind: 'synthesized'}, derived_from: [${recipes.map((r) => `'${r.slug}'`).join(", ")}] (only the ones you actually drew on), no provider, structured ingredients (source 'kitchen' for what the pantry has) and steps with techniques/uses, tags, and a blurb.`
841
+ };
842
+ }
843
+
844
+ // src/mcp/tools/pb_synthesis_context.ts
845
+ var pb_synthesis_context_default = defineTool({
846
+ name: "pb_synthesis_context",
847
+ description: "Material for inventing a new recipe from the collection: a few relevant source recipes in compact form, the ingredients, pairings, techniques, cuisines and flavours they share, and (with use_pantry) what the pantry covers and what is commonly missing. Pick sources by `query`, explicit `slugs`, pantry fit, or tag filters. Then write the recipe and save it with pb_save_recipe (source.kind 'synthesized', derived_from the slugs you drew on).",
848
+ schema: {
849
+ query: z17.string().optional().describe('What to make, e.g. "quick spicy noodle dinner"'),
850
+ slugs: z17.array(z17.string()).optional(),
851
+ use_pantry: z17.boolean().optional(),
852
+ ingredients: z17.array(z17.string()).optional().describe("Extra things available right now"),
853
+ tags: z17.partialRecord(z17.enum(ALL_CATEGORIES), z17.array(z17.string())).optional(),
854
+ exclude_allergens: z17.array(z17.string()).optional(),
855
+ max_total_min: z17.number().optional(),
856
+ n: z17.number().int().min(1).max(12).optional().describe("Number of source recipes (default 5)")
857
+ },
858
+ handler: async ({ query, slugs, use_pantry, ingredients, n, ...filters }, ctx) => text(await synthesisContext(ctx, { query, slugs, use_pantry, ingredients, n, filters }))
859
+ });
860
+
861
+ // src/mcp/tools/index.ts
862
+ var tools = [
863
+ // find and cook
864
+ pb_search_default,
865
+ pb_get_recipe_default,
866
+ pb_pantry_match_default,
867
+ pb_synthesis_context_default,
868
+ pb_list_tags_default,
869
+ // pantry
870
+ pb_pantry_list,
871
+ pb_pantry_add,
872
+ pb_pantry_remove,
873
+ // capture
874
+ pb_providers_default,
875
+ pb_ingest_source_default,
876
+ pb_inbox_next_default,
877
+ pb_inbox_image_default,
878
+ pb_inbox_rotate_default,
879
+ pb_inbox_resolve_default,
880
+ pb_inbox_list_default,
881
+ pb_save_recipe_default,
882
+ // maintenance
883
+ pb_open_ui_default,
884
+ pb_merge_ingredients_default,
885
+ pb_stats_default,
886
+ pb_rebuild_default
887
+ ];
888
+
889
+ // src/server.ts
890
+ var pkg = { version: packageVersion() };
891
+ async function main() {
892
+ if (process.argv.includes("--probe")) {
893
+ process.stdout.write(
894
+ `${JSON.stringify({ ok: true, name: "pantrybase", version: pkg.version, node: process.version, tools: tools.map((t) => t.name) })}
895
+ `
896
+ );
897
+ return;
898
+ }
899
+ const server = new McpServer({ name: "pantrybase", version: pkg.version });
900
+ registerTools(server, tools);
901
+ const shutdown = () => {
902
+ closeAllContexts();
903
+ process.exit(0);
904
+ };
905
+ process.on("SIGINT", shutdown);
906
+ process.on("SIGTERM", shutdown);
907
+ process.stdin.on("end", shutdown);
908
+ await server.connect(new StdioServerTransport());
909
+ log(`pantrybase ${pkg.version} serving ${tools.length} tools over stdio`);
910
+ }
911
+ main().catch((e) => {
912
+ log("fatal:", e);
913
+ process.exit(1);
914
+ });
915
+ //# sourceMappingURL=server.js.map