pantrybase 0.2.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/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,15 +28,19 @@ import {
26
28
  recipeInputSchema,
27
29
  reconcile,
28
30
  removeFromPantry,
31
+ renderShoppingList,
29
32
  resolveItem,
30
33
  resolveItems,
31
34
  rotateItem,
35
+ savePlan,
32
36
  scoreRecipes,
33
37
  searchRecipes,
38
+ shoppingList,
34
39
  startUiServer,
40
+ suggestRecipes,
35
41
  viewItems,
36
42
  writeRecipeFile
37
- } from "./chunk-QUWSBZ2V.js";
43
+ } from "./chunk-IUQQ3I2E.js";
38
44
 
39
45
  // src/server.ts
40
46
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -482,13 +488,163 @@ var pb_pantry_match_default = defineTool({
482
488
  }
483
489
  });
484
490
 
485
- // src/mcp/tools/pb_providers.ts
491
+ // src/mcp/tools/pb_plan.ts
486
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
+
641
+ // src/mcp/tools/pb_providers.ts
642
+ import { z as z16 } from "zod";
487
643
  var pb_providers_default = defineTool({
488
644
  name: "pb_providers",
489
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.",
490
646
  schema: {
491
- id: z13.string().optional().describe('e.g. "ucook"; "generic" for anything else')
647
+ id: z16.string().optional().describe('e.g. "ucook"; "generic" for anything else')
492
648
  },
493
649
  handler: ({ id }, ctx) => {
494
650
  if (!id) {
@@ -528,13 +684,13 @@ var pb_providers_default = defineTool({
528
684
  });
529
685
 
530
686
  // src/mcp/tools/pb_rebuild.ts
531
- import { z as z14 } from "zod";
687
+ import { z as z17 } from "zod";
532
688
  var pb_rebuild_default = defineTool({
533
689
  name: "pb_rebuild",
534
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.",
535
691
  schema: {
536
- full: z14.boolean().optional(),
537
- reembed: z14.boolean().optional()
692
+ full: z17.boolean().optional(),
693
+ reembed: z17.boolean().optional()
538
694
  },
539
695
  handler: ({ full, reembed }, ctx) => {
540
696
  const reloaded = ctx.refresh(true);
@@ -544,7 +700,7 @@ var pb_rebuild_default = defineTool({
544
700
  });
545
701
 
546
702
  // src/mcp/tools/pb_save_recipe.ts
547
- import { z as z15 } from "zod";
703
+ import { z as z18 } from "zod";
548
704
 
549
705
  // src/files/slug.ts
550
706
  var MAX_SLUG = 60;
@@ -645,10 +801,10 @@ var pb_save_recipe_default = defineTool({
645
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.",
646
802
  schema: {
647
803
  ...recipeInputSchema.shape,
648
- ingest: z15.array(z15.object({ id: z15.number().int(), side: z15.enum(IMAGE_SIDES) })).optional().describe(
804
+ ingest: z18.array(z18.object({ id: z18.number().int(), side: z18.enum(IMAGE_SIDES) })).optional().describe(
649
805
  "Inbox photos of this card, e.g. [{id: 12, side: 'front'}, {id: 13, side: 'back'}]"
650
806
  ),
651
- allow_duplicate: z15.boolean().optional()
807
+ allow_duplicate: z18.boolean().optional()
652
808
  },
653
809
  handler: ({ allow_duplicate, ingest, ...input }, ctx) => {
654
810
  return text(saveRecipe(ctx, input, { allow_duplicate, ingest }));
@@ -656,22 +812,22 @@ var pb_save_recipe_default = defineTool({
656
812
  });
657
813
 
658
814
  // src/mcp/tools/pb_search.ts
659
- import { z as z16 } from "zod";
815
+ import { z as z19 } from "zod";
660
816
  var pb_search_default = defineTool({
661
817
  name: "pb_search",
662
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.",
663
819
  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()
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()
675
831
  },
676
832
  handler: async ({ query, limit, mode, ...filters }, ctx) => {
677
833
  const res = await searchRecipes(ctx, { query, limit, mode, filters });
@@ -712,7 +868,7 @@ var pb_stats_default = defineTool({
712
868
  });
713
869
 
714
870
  // src/mcp/tools/pb_synthesis_context.ts
715
- import { z as z17 } from "zod";
871
+ import { z as z20 } from "zod";
716
872
 
717
873
  // src/synthesis/context.ts
718
874
  var STEP_CHARS = 220;
@@ -846,14 +1002,14 @@ var pb_synthesis_context_default = defineTool({
846
1002
  name: "pb_synthesis_context",
847
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).",
848
1004
  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)")
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)")
857
1013
  },
858
1014
  handler: async ({ query, slugs, use_pantry, ingredients, n, ...filters }, ctx) => text(await synthesisContext(ctx, { query, slugs, use_pantry, ingredients, n, filters }))
859
1015
  });
@@ -870,6 +1026,10 @@ var tools = [
870
1026
  pb_pantry_list,
871
1027
  pb_pantry_add,
872
1028
  pb_pantry_remove,
1029
+ // meal planning
1030
+ pb_plan_suggest_default,
1031
+ pb_plan_default,
1032
+ pb_plan_shopping_list_default,
873
1033
  // capture
874
1034
  pb_providers_default,
875
1035
  pb_ingest_source_default,