pantrybase 0.1.0 → 0.3.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/cli.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  resolveProjectRoot,
13
13
  searchRecipes,
14
14
  startUiServer
15
- } from "./chunk-2EMC6TJB.js";
15
+ } from "./chunk-IUQQ3I2E.js";
16
16
 
17
17
  // src/cli.ts
18
18
  import { spawn } from "child_process";
package/dist/server.js CHANGED
@@ -5,9 +5,11 @@ import {
5
5
  MODEL_MAX_PX,
6
6
  SOURCE_KINDS,
7
7
  addToPantry,
8
+ autofillPlan,
8
9
  availableIngredients,
9
10
  closeAllContexts,
10
11
  finalizeAttach,
12
+ getPlan,
11
13
  imageForModel,
12
14
  importPaths,
13
15
  listPantry,
@@ -26,14 +28,19 @@ import {
26
28
  recipeInputSchema,
27
29
  reconcile,
28
30
  removeFromPantry,
31
+ renderShoppingList,
29
32
  resolveItem,
30
33
  resolveItems,
34
+ rotateItem,
35
+ savePlan,
31
36
  scoreRecipes,
32
37
  searchRecipes,
38
+ shoppingList,
33
39
  startUiServer,
34
- viewItem,
40
+ suggestRecipes,
41
+ viewItems,
35
42
  writeRecipeFile
36
- } from "./chunk-2EMC6TJB.js";
43
+ } from "./chunk-IUQQ3I2E.js";
37
44
 
38
45
  // src/server.ts
39
46
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -136,7 +143,7 @@ import path from "path";
136
143
  import { z as z3 } from "zod";
137
144
  var pb_inbox_image_default = defineTool({
138
145
  name: "pb_inbox_image",
139
- 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.",
146
+ 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.",
140
147
  schema: {
141
148
  id: z3.number().int(),
142
149
  max_px: z3.number().int().min(400).max(2400).optional()
@@ -175,7 +182,10 @@ var pb_inbox_list_default = defineTool({
175
182
  },
176
183
  handler: ({ status, limit, offset }, ctx) => {
177
184
  const items = ctx.store.listIngest({ status, limit: limit ?? 20, offset });
178
- return text({ counts: ctx.store.ingestCounts(), items: items.map((i) => viewItem(i)) });
185
+ return text({
186
+ counts: ctx.store.ingestCounts(),
187
+ items: viewItems(items, ctx.store.allIngest())
188
+ });
179
189
  }
180
190
  });
181
191
 
@@ -183,7 +193,7 @@ var pb_inbox_list_default = defineTool({
183
193
  import { z as z5 } from "zod";
184
194
  var pb_inbox_next_default = defineTool({
185
195
  name: "pb_inbox_next",
186
- 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.",
196
+ 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.",
187
197
  schema: {
188
198
  window: z5.number().int().min(1).max(12).optional().describe("How many photos to return (default 4)")
189
199
  },
@@ -210,14 +220,26 @@ var pb_inbox_resolve_default = defineTool({
210
220
  }
211
221
  });
212
222
 
213
- // src/mcp/tools/pb_ingest_source.ts
223
+ // src/mcp/tools/pb_inbox_rotate.ts
214
224
  import { z as z7 } from "zod";
225
+ var pb_inbox_rotate_default = defineTool({
226
+ name: "pb_inbox_rotate",
227
+ 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.",
228
+ schema: {
229
+ id: z7.number().int(),
230
+ degrees: z7.union([z7.literal(90), z7.literal(180), z7.literal(270)])
231
+ },
232
+ handler: async ({ id, degrees }, ctx) => text({ rotated: degrees, item: await rotateItem(ctx, id, degrees) })
233
+ });
234
+
235
+ // src/mcp/tools/pb_ingest_source.ts
236
+ import { z as z8 } from "zod";
215
237
  var pb_ingest_source_default = defineTool({
216
238
  name: "pb_ingest_source",
217
239
  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.",
218
240
  schema: {
219
- paths: z7.array(z7.string()).min(1).describe("Files or folders; relative to the project, absolute, or ~/\u2026"),
220
- recursive: z7.boolean().optional()
241
+ paths: z8.array(z8.string()).min(1).describe("Files or folders; relative to the project, absolute, or ~/\u2026"),
242
+ recursive: z8.boolean().optional()
221
243
  },
222
244
  handler: async ({ paths, recursive }, ctx) => {
223
245
  const r = await importPaths(ctx, paths, { recursive });
@@ -234,13 +256,13 @@ var pb_ingest_source_default = defineTool({
234
256
  });
235
257
 
236
258
  // src/mcp/tools/pb_list_tags.ts
237
- import { z as z8 } from "zod";
259
+ import { z as z9 } from "zod";
238
260
  var pb_list_tags_default = defineTool({
239
261
  name: "pb_list_tags",
240
262
  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.",
241
263
  schema: {
242
- category: z8.enum(ALL_CATEGORIES).optional(),
243
- vocabulary: z8.boolean().optional()
264
+ category: z9.enum(ALL_CATEGORIES).optional(),
265
+ vocabulary: z9.boolean().optional()
244
266
  },
245
267
  handler: ({ category, vocabulary }, ctx) => {
246
268
  const used = {};
@@ -259,7 +281,7 @@ var pb_list_tags_default = defineTool({
259
281
  });
260
282
 
261
283
  // src/mcp/tools/pb_merge_ingredients.ts
262
- import { z as z9 } from "zod";
284
+ import { z as z10 } from "zod";
263
285
 
264
286
  // src/ingredients/merge.ts
265
287
  import fs from "fs";
@@ -363,8 +385,8 @@ var pb_merge_ingredients_default = defineTool({
363
385
  name: "pb_merge_ingredients",
364
386
  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.",
365
387
  schema: {
366
- from: z9.string().min(1),
367
- into: z9.string().min(1)
388
+ from: z10.string().min(1),
389
+ into: z10.string().min(1)
368
390
  },
369
391
  handler: ({ from, into }, ctx) => text(mergeIngredients(ctx, from, into))
370
392
  });
@@ -381,7 +403,7 @@ var pb_open_ui_default = defineTool({
381
403
  });
382
404
 
383
405
  // src/mcp/tools/pb_pantry.ts
384
- import { z as z10 } from "zod";
406
+ import { z as z11 } from "zod";
385
407
  var pb_pantry_list = defineTool({
386
408
  name: "pb_pantry_list",
387
409
  description: "What's in the pantry: canonical ingredient names with optional quantity, unit and note.",
@@ -403,12 +425,12 @@ var pb_pantry_add = defineTool({
403
425
  name: "pb_pantry_add",
404
426
  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.",
405
427
  schema: {
406
- items: z10.array(
407
- z10.object({
408
- name: z10.string().min(1),
409
- qty: z10.number().nullable().optional(),
410
- unit: z10.string().nullable().optional(),
411
- note: z10.string().nullable().optional()
428
+ items: z11.array(
429
+ z11.object({
430
+ name: z11.string().min(1),
431
+ qty: z11.number().nullable().optional(),
432
+ unit: z11.string().nullable().optional(),
433
+ note: z11.string().nullable().optional()
412
434
  })
413
435
  ).min(1)
414
436
  },
@@ -418,8 +440,8 @@ var pb_pantry_remove = defineTool({
418
440
  name: "pb_pantry_remove",
419
441
  description: "Remove items from the pantry by name, or everything with all:true.",
420
442
  schema: {
421
- names: z10.array(z10.string()).optional(),
422
- all: z10.boolean().optional()
443
+ names: z11.array(z11.string()).optional(),
444
+ all: z11.boolean().optional()
423
445
  },
424
446
  handler: ({ names, all }, ctx) => {
425
447
  if (all) return text({ cleared: ctx.store.pantryClear() });
@@ -428,19 +450,19 @@ var pb_pantry_remove = defineTool({
428
450
  });
429
451
 
430
452
  // src/mcp/tools/pb_pantry_match.ts
431
- import { z as z11 } from "zod";
453
+ import { z as z12 } from "zod";
432
454
  var pb_pantry_match_default = defineTool({
433
455
  name: "pb_pantry_match",
434
456
  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).",
435
457
  schema: {
436
- ingredients: z11.array(z11.string()).optional(),
437
- use_pantry: z11.boolean().optional().describe("default true"),
438
- min_coverage: z11.number().min(0).max(1).optional().describe("default 0.5"),
439
- max_missing: z11.number().int().min(0).optional(),
440
- limit: z11.number().int().min(1).max(50).optional().describe("default 10"),
441
- tags: z11.partialRecord(z11.enum(ALL_CATEGORIES), z11.array(z11.string())).optional(),
442
- exclude_allergens: z11.array(z11.string()).optional(),
443
- max_total_min: z11.number().optional()
458
+ ingredients: z12.array(z12.string()).optional(),
459
+ use_pantry: z12.boolean().optional().describe("default true"),
460
+ min_coverage: z12.number().min(0).max(1).optional().describe("default 0.5"),
461
+ max_missing: z12.number().int().min(0).optional(),
462
+ limit: z12.number().int().min(1).max(50).optional().describe("default 10"),
463
+ tags: z12.partialRecord(z12.enum(ALL_CATEGORIES), z12.array(z12.string())).optional(),
464
+ exclude_allergens: z12.array(z12.string()).optional(),
465
+ max_total_min: z12.number().optional()
444
466
  },
445
467
  handler: ({ ingredients, use_pantry, min_coverage, max_missing, limit, ...filters }, ctx) => {
446
468
  const r = matchRecipes(ctx, {
@@ -466,13 +488,163 @@ var pb_pantry_match_default = defineTool({
466
488
  }
467
489
  });
468
490
 
491
+ // src/mcp/tools/pb_plan.ts
492
+ import { z as z13 } from "zod";
493
+ var pb_plan_default = defineTool({
494
+ name: "pb_plan",
495
+ description: "The saved meal plan (shared with the web UI). get: the planned recipes and how much they share. set: replace the plan with `slugs`. add / remove: adjust it. clear: empty it. `servings` sets the portions wanted per recipe (quantities on the shopping list scale from the recipe's own servings).",
496
+ schema: {
497
+ action: z13.enum(["get", "set", "add", "remove", "clear"]),
498
+ slugs: z13.array(z13.string()).optional(),
499
+ servings: z13.record(z13.string(), z13.number().positive()).optional().describe("slug \u2192 portions wanted, e.g. {'harissa-chicken-traybake': 4}")
500
+ },
501
+ handler: ({ action, slugs, servings }, ctx) => {
502
+ const current = getPlan(ctx);
503
+ let items = current.items;
504
+ if (action === "clear") items = [];
505
+ else if (action === "set") items = (slugs ?? []).map((slug) => ({ slug }));
506
+ else if (action === "add") {
507
+ if (!slugs?.length) return errorResult("slugs required");
508
+ const have = new Set(items.map((i) => i.slug));
509
+ items = [...items, ...slugs.filter((s) => !have.has(s)).map((slug) => ({ slug }))];
510
+ } else if (action === "remove") {
511
+ if (!slugs?.length) return errorResult("slugs required");
512
+ const drop = new Set(slugs);
513
+ items = items.filter((i) => !drop.has(i.slug));
514
+ }
515
+ if (servings) {
516
+ items = items.map((i) => servings[i.slug] ? { ...i, servings: servings[i.slug] } : i);
517
+ }
518
+ const unknown = (slugs ?? []).filter((s) => !ctx.store.getRecipe(s));
519
+ const saved = action === "get" ? current : savePlan(ctx, { items, confirmed: current.confirmed });
520
+ const { plan } = suggestRecipes(ctx, { plan: saved.items.map((i) => i.slug), limit: 1 });
521
+ return text({
522
+ recipes: saved.items.map((i) => ({
523
+ slug: i.slug,
524
+ title: plan.recipes.find((r) => r.slug === i.slug)?.title,
525
+ servings: i.servings ?? void 0
526
+ })),
527
+ to_buy: plan.to_buy,
528
+ from_pantry: plan.from_pantry,
529
+ shared: plan.shared,
530
+ reuse: plan.reuse,
531
+ confirmed_pantry: saved.confirmed.length ? saved.confirmed : void 0,
532
+ unknown_slugs: unknown.length ? unknown : void 0
533
+ });
534
+ }
535
+ });
536
+
537
+ // src/mcp/tools/pb_plan_shopping_list.ts
538
+ import { z as z14 } from "zod";
539
+ var pb_plan_shopping_list_default = defineTool({
540
+ name: "pb_plan_shopping_list",
541
+ description: "Grocery list for the saved plan (or `slugs`): distinct items to buy with merged quantities, grouped by aisle, each with the recipes that use it. `pantry_check` lists the pantry items the plan relies on \u2014 ask the user to confirm those are really there before the list is final; pass the ones they confirm as `confirmed` and the ones they're out of as `out_of` (those leave the pantry and move onto the list). `needs_confirmation` is empty once everything is settled. Staples (salt, oil\u2026) are listed separately.",
542
+ schema: {
543
+ slugs: z14.array(z14.string()).optional().describe("defaults to the saved plan"),
544
+ servings: z14.record(z14.string(), z14.number().positive()).optional().describe("slug \u2192 portions wanted; scales quantities"),
545
+ confirmed: z14.array(z14.string()).optional().describe("pantry items the user confirmed"),
546
+ out_of: z14.array(z14.string()).optional().describe("pantry items the user is out of"),
547
+ format: z14.enum(["json", "text"]).optional().describe("default json; text is paste-ready")
548
+ },
549
+ handler: ({ slugs, servings, confirmed, out_of, format }, ctx) => {
550
+ const items = slugs?.map((slug) => ({ slug, servings: servings?.[slug] ?? null }));
551
+ const list = shoppingList(ctx, { items, confirmed, out_of });
552
+ if (format === "text") return text(renderShoppingList(list));
553
+ return text({
554
+ recipes: list.recipes.map((r) => ({
555
+ title: r.title,
556
+ servings: r.servings ?? void 0,
557
+ scaled: r.scale !== 1 ? `\xD7${Math.round(r.scale * 100) / 100}` : void 0
558
+ })),
559
+ buy: list.buy.map((l) => ({
560
+ item: l.canonical,
561
+ amount: l.amount ?? void 0,
562
+ aisle: l.aisle,
563
+ for: l.for
564
+ })),
565
+ pantry_check: list.pantry_check.map((c) => ({
566
+ item: c.canonical,
567
+ amount: c.amount ?? void 0,
568
+ in_pantry: c.in_pantry ?? void 0,
569
+ for: c.for,
570
+ confirmed: c.confirmed
571
+ })),
572
+ needs_confirmation: list.needs_confirmation.length ? list.needs_confirmation : void 0,
573
+ optional: list.optional.length ? list.optional.map((l) => l.canonical) : void 0,
574
+ staples: list.staples,
575
+ summary: {
576
+ to_buy: list.summary.to_buy,
577
+ from_pantry: list.summary.from_pantry,
578
+ shared: list.summary.shared,
579
+ reuse: list.summary.reuse
580
+ },
581
+ removed_from_pantry: list.removed_from_pantry.length ? list.removed_from_pantry : void 0,
582
+ unknown_slugs: list.unknown_slugs.length ? list.unknown_slugs : void 0
583
+ });
584
+ }
585
+ });
586
+
587
+ // src/mcp/tools/pb_plan_suggest.ts
588
+ import { z as z15 } from "zod";
589
+ var pb_plan_suggest_default = defineTool({
590
+ name: "pb_plan_suggest",
591
+ description: "Meal planning around shared ingredients. Given a seed recipe and/or the current plan, ranks recipes by how much of their shopping is already covered \u2014 `shares` (already on the list for another planned recipe), `from_pantry`, and `new` (what they would add). `reuse` is the covered share (0\u20131), weighted so fresh items count more than dry goods. Repeating a planned dish is penalised unless variety:false. With `auto: n` it instead fills the plan with n more recipes that minimise the whole list (beam search) and returns the resulting slugs \u2014 save them with pb_plan. Nothing is saved by this tool.",
592
+ schema: {
593
+ seed: z15.string().optional().describe("recipe slug to plan around"),
594
+ plan: z15.array(z15.string()).optional().describe("slugs already planned; defaults to the saved plan"),
595
+ auto: z15.number().int().min(1).max(14).optional().describe("fill the plan with this many more"),
596
+ limit: z15.number().int().min(1).max(50).optional().describe("default 8"),
597
+ variety: z15.boolean().optional().describe("default true"),
598
+ use_pantry: z15.boolean().optional().describe("default true"),
599
+ tags: z15.partialRecord(z15.enum(ALL_CATEGORIES), z15.array(z15.string())).optional(),
600
+ exclude_allergens: z15.array(z15.string()).optional(),
601
+ exclude_ingredients: z15.array(z15.string()).optional(),
602
+ max_total_min: z15.number().optional()
603
+ },
604
+ handler: ({ seed, plan, auto, limit, variety, use_pantry, ...filters }, ctx) => {
605
+ if (auto) {
606
+ const r2 = autofillPlan(ctx, { seed, plan, count: auto, variety, use_pantry, filters });
607
+ return text({
608
+ plan_slugs: r2.slugs,
609
+ added: r2.added.map((s) => ({
610
+ slug: s.slug,
611
+ title: s.title,
612
+ reuse: s.reuse,
613
+ shares: s.shares,
614
+ from_pantry: s.from_pantry,
615
+ new: s.new,
616
+ repeats: s.repeats
617
+ })),
618
+ summary: r2.plan,
619
+ unknown_slugs: r2.unknown_slugs.length ? r2.unknown_slugs : void 0,
620
+ next: "Show the user the plan; when they agree, pb_plan {action:'set', slugs: plan_slugs}."
621
+ });
622
+ }
623
+ const r = suggestRecipes(ctx, { seed, plan, limit: limit ?? 8, variety, use_pantry, filters });
624
+ return text({
625
+ plan: r.plan,
626
+ suggestions: r.suggestions.map((s) => ({
627
+ slug: s.slug,
628
+ title: s.title,
629
+ total_min: s.total_min ?? void 0,
630
+ reuse: s.reuse,
631
+ shares: s.shares,
632
+ from_pantry: s.from_pantry,
633
+ new: s.new,
634
+ repeats: s.repeats
635
+ })),
636
+ unknown_slugs: r.unknown_slugs.length ? r.unknown_slugs : void 0
637
+ });
638
+ }
639
+ });
640
+
469
641
  // src/mcp/tools/pb_providers.ts
470
- import { z as z12 } from "zod";
642
+ import { z as z16 } from "zod";
471
643
  var pb_providers_default = defineTool({
472
644
  name: "pb_providers",
473
645
  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.",
474
646
  schema: {
475
- id: z12.string().optional().describe('e.g. "ucook"; "generic" for anything else')
647
+ id: z16.string().optional().describe('e.g. "ucook"; "generic" for anything else')
476
648
  },
477
649
  handler: ({ id }, ctx) => {
478
650
  if (!id) {
@@ -500,6 +672,7 @@ var pb_providers_default = defineTool({
500
672
  `- Brands stripped from canonical names: ${p.brands.join(", ") || "none"}`,
501
673
  `- Kitchen items treated as equipment: ${Object.keys(p.equipment_terms).join(", ") || "none"}`,
502
674
  `- Nutrition basis default: ${p.nutrition_basis}; default servings: ${p.default_servings ?? "none"}`,
675
+ `- Upright card shape: ${p.orientation ?? "varies"} (photos that don't match are flagged with rotation_hint; fix them with pb_inbox_rotate)`,
503
676
  "",
504
677
  "## Tag vocabulary (closed = only these values; open = top values, new ones allowed)",
505
678
  ...Object.entries(summary).map(
@@ -511,13 +684,13 @@ var pb_providers_default = defineTool({
511
684
  });
512
685
 
513
686
  // src/mcp/tools/pb_rebuild.ts
514
- import { z as z13 } from "zod";
687
+ import { z as z17 } from "zod";
515
688
  var pb_rebuild_default = defineTool({
516
689
  name: "pb_rebuild",
517
690
  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.",
518
691
  schema: {
519
- full: z13.boolean().optional(),
520
- reembed: z13.boolean().optional()
692
+ full: z17.boolean().optional(),
693
+ reembed: z17.boolean().optional()
521
694
  },
522
695
  handler: ({ full, reembed }, ctx) => {
523
696
  const reloaded = ctx.refresh(true);
@@ -527,7 +700,7 @@ var pb_rebuild_default = defineTool({
527
700
  });
528
701
 
529
702
  // src/mcp/tools/pb_save_recipe.ts
530
- import { z as z14 } from "zod";
703
+ import { z as z18 } from "zod";
531
704
 
532
705
  // src/files/slug.ts
533
706
  var MAX_SLUG = 60;
@@ -628,10 +801,10 @@ var pb_save_recipe_default = defineTool({
628
801
  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.",
629
802
  schema: {
630
803
  ...recipeInputSchema.shape,
631
- ingest: z14.array(z14.object({ id: z14.number().int(), side: z14.enum(IMAGE_SIDES) })).optional().describe(
804
+ ingest: z18.array(z18.object({ id: z18.number().int(), side: z18.enum(IMAGE_SIDES) })).optional().describe(
632
805
  "Inbox photos of this card, e.g. [{id: 12, side: 'front'}, {id: 13, side: 'back'}]"
633
806
  ),
634
- allow_duplicate: z14.boolean().optional()
807
+ allow_duplicate: z18.boolean().optional()
635
808
  },
636
809
  handler: ({ allow_duplicate, ingest, ...input }, ctx) => {
637
810
  return text(saveRecipe(ctx, input, { allow_duplicate, ingest }));
@@ -639,22 +812,22 @@ var pb_save_recipe_default = defineTool({
639
812
  });
640
813
 
641
814
  // src/mcp/tools/pb_search.ts
642
- import { z as z15 } from "zod";
815
+ import { z as z19 } from "zod";
643
816
  var pb_search_default = defineTool({
644
817
  name: "pb_search",
645
818
  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.",
646
819
  schema: {
647
- query: z15.string().optional().describe("Free text: dish, ingredient, technique, mood, occasion\u2026"),
648
- limit: z15.number().int().min(1).max(100).optional().describe("default 10"),
649
- tags: z15.partialRecord(z15.enum(ALL_CATEGORIES), z15.array(z15.string())).optional().describe("e.g. {cuisine:['thai'], diet:['vegetarian']} \u2014 OR within a category, AND across"),
650
- exclude_allergens: z15.array(z15.string()).optional().describe('e.g. ["dairy", "tree-nuts"]'),
651
- exclude_ingredients: z15.array(z15.string()).optional(),
652
- max_total_min: z15.number().optional(),
653
- max_active_min: z15.number().optional().describe("Hands-on minutes"),
654
- provider: z15.string().optional(),
655
- source_kind: z15.enum(SOURCE_KINDS).optional(),
656
- needs_review: z15.boolean().optional().describe("true = only recipes flagged for review"),
657
- mode: z15.enum(["hybrid", "lexical", "semantic"]).optional()
820
+ query: z19.string().optional().describe("Free text: dish, ingredient, technique, mood, occasion\u2026"),
821
+ limit: z19.number().int().min(1).max(100).optional().describe("default 10"),
822
+ tags: z19.partialRecord(z19.enum(ALL_CATEGORIES), z19.array(z19.string())).optional().describe("e.g. {cuisine:['thai'], diet:['vegetarian']} \u2014 OR within a category, AND across"),
823
+ exclude_allergens: z19.array(z19.string()).optional().describe('e.g. ["dairy", "tree-nuts"]'),
824
+ exclude_ingredients: z19.array(z19.string()).optional(),
825
+ max_total_min: z19.number().optional(),
826
+ max_active_min: z19.number().optional().describe("Hands-on minutes"),
827
+ provider: z19.string().optional(),
828
+ source_kind: z19.enum(SOURCE_KINDS).optional(),
829
+ needs_review: z19.boolean().optional().describe("true = only recipes flagged for review"),
830
+ mode: z19.enum(["hybrid", "lexical", "semantic"]).optional()
658
831
  },
659
832
  handler: async ({ query, limit, mode, ...filters }, ctx) => {
660
833
  const res = await searchRecipes(ctx, { query, limit, mode, filters });
@@ -695,7 +868,7 @@ var pb_stats_default = defineTool({
695
868
  });
696
869
 
697
870
  // src/mcp/tools/pb_synthesis_context.ts
698
- import { z as z16 } from "zod";
871
+ import { z as z20 } from "zod";
699
872
 
700
873
  // src/synthesis/context.ts
701
874
  var STEP_CHARS = 220;
@@ -829,14 +1002,14 @@ var pb_synthesis_context_default = defineTool({
829
1002
  name: "pb_synthesis_context",
830
1003
  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).",
831
1004
  schema: {
832
- query: z16.string().optional().describe('What to make, e.g. "quick spicy noodle dinner"'),
833
- slugs: z16.array(z16.string()).optional(),
834
- use_pantry: z16.boolean().optional(),
835
- ingredients: z16.array(z16.string()).optional().describe("Extra things available right now"),
836
- tags: z16.partialRecord(z16.enum(ALL_CATEGORIES), z16.array(z16.string())).optional(),
837
- exclude_allergens: z16.array(z16.string()).optional(),
838
- max_total_min: z16.number().optional(),
839
- n: z16.number().int().min(1).max(12).optional().describe("Number of source recipes (default 5)")
1005
+ query: z20.string().optional().describe('What to make, e.g. "quick spicy noodle dinner"'),
1006
+ slugs: z20.array(z20.string()).optional(),
1007
+ use_pantry: z20.boolean().optional(),
1008
+ ingredients: z20.array(z20.string()).optional().describe("Extra things available right now"),
1009
+ tags: z20.partialRecord(z20.enum(ALL_CATEGORIES), z20.array(z20.string())).optional(),
1010
+ exclude_allergens: z20.array(z20.string()).optional(),
1011
+ max_total_min: z20.number().optional(),
1012
+ n: z20.number().int().min(1).max(12).optional().describe("Number of source recipes (default 5)")
840
1013
  },
841
1014
  handler: async ({ query, slugs, use_pantry, ingredients, n, ...filters }, ctx) => text(await synthesisContext(ctx, { query, slugs, use_pantry, ingredients, n, filters }))
842
1015
  });
@@ -853,11 +1026,16 @@ var tools = [
853
1026
  pb_pantry_list,
854
1027
  pb_pantry_add,
855
1028
  pb_pantry_remove,
1029
+ // meal planning
1030
+ pb_plan_suggest_default,
1031
+ pb_plan_default,
1032
+ pb_plan_shopping_list_default,
856
1033
  // capture
857
1034
  pb_providers_default,
858
1035
  pb_ingest_source_default,
859
1036
  pb_inbox_next_default,
860
1037
  pb_inbox_image_default,
1038
+ pb_inbox_rotate_default,
861
1039
  pb_inbox_resolve_default,
862
1040
  pb_inbox_list_default,
863
1041
  pb_save_recipe_default,