pantrybase 0.1.0 → 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.
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "pantrybase",
3
3
  "displayName": "Pantrybase",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Local-first recipe knowledge base for Claude Code: capture meal-box recipe cards from photos, search by ingredient, tag, allergen or mood, match recipes to your pantry, and synthesize new recipes. SQLite FTS5 + vector search, markdown recipes as the source of truth.",
6
6
  "author": {
7
7
  "name": "Ryan Dick"
8
8
  },
9
9
  "license": "MIT",
10
- "keywords": ["recipes", "pantry", "meal-kit", "knowledge-graph", "search"],
10
+ "keywords": [
11
+ "recipes",
12
+ "pantry",
13
+ "meal-kit",
14
+ "knowledge-graph",
15
+ "search"
16
+ ],
11
17
  "mcpServers": {
12
18
  "pantrybase": {
13
19
  "command": "${CLAUDE_PLUGIN_ROOT}/bin/pb-server",
@@ -1056,6 +1056,7 @@ function loadProfile(dir) {
1056
1056
  recognise: raw.recognise ?? [],
1057
1057
  sides: { front: raw.sides?.front ?? [], back: raw.sides?.back ?? [] },
1058
1058
  pairing: raw.pairing ?? "content",
1059
+ orientation: raw.orientation ?? null,
1059
1060
  nutrition_basis: raw.nutrition_basis ?? "per_serving",
1060
1061
  default_servings: raw.default_servings ?? null,
1061
1062
  kitchen_section: raw.kitchen_section ?? null,
@@ -2421,6 +2422,25 @@ async function openImage(file) {
2421
2422
  const input = HEIF_EXTS.has(path6.extname(file).toLowerCase()) ? await heifToJpeg(file) : file;
2422
2423
  return sharp(input, { failOn: "none" }).rotate();
2423
2424
  }
2425
+ async function rotateFile(file, degrees) {
2426
+ const ext = path6.extname(file).toLowerCase();
2427
+ const upright = await (await openImage(file)).toBuffer();
2428
+ let pipeline = sharp(upright, { failOn: "none" }).rotate(degrees);
2429
+ let target = file;
2430
+ if (ext === ".png") pipeline = pipeline.png();
2431
+ else if (ext === ".webp") pipeline = pipeline.webp({ quality: 92 });
2432
+ else if (ext === ".tif" || ext === ".tiff") pipeline = pipeline.tiff();
2433
+ else {
2434
+ pipeline = pipeline.jpeg({ quality: 92, mozjpeg: true });
2435
+ if (HEIF_EXTS.has(ext)) target = `${file.slice(0, -ext.length)}.jpg`;
2436
+ }
2437
+ const out = await pipeline.toBuffer();
2438
+ const tmp = `${target}.rotating-${process.pid}`;
2439
+ fs9.writeFileSync(tmp, out);
2440
+ fs9.renameSync(tmp, target);
2441
+ if (target !== file) fs9.rmSync(file, { force: true });
2442
+ return target;
2443
+ }
2424
2444
  async function imageForModel(file, maxPx = MODEL_MAX_PX) {
2425
2445
  const base = await (await openImage(file)).resize(maxPx, maxPx, { fit: "inside", withoutEnlargement: true }).toBuffer({ resolveWithObject: true });
2426
2446
  let data = base.data;
@@ -2686,6 +2706,29 @@ async function addUpload(ctx, name, data) {
2686
2706
  await scanInbox(ctx);
2687
2707
  return ctx.store.ingestByPath(relativeToData(ctx.paths, target));
2688
2708
  }
2709
+ function shapeOf(item) {
2710
+ const w = item.width ?? 0;
2711
+ const h = item.height ?? 0;
2712
+ if (w > h * 1.1) return "landscape";
2713
+ if (h > w * 1.1) return "portrait";
2714
+ return "square";
2715
+ }
2716
+ function dominantShape(all) {
2717
+ const counts = { landscape: 0, portrait: 0, square: 0 };
2718
+ for (const i of all) if (i.width && i.height) counts[shapeOf(i)]++;
2719
+ const total = counts.landscape + counts.portrait + counts.square;
2720
+ if (total < 3) return null;
2721
+ const [shape, n] = Object.entries(counts).sort(
2722
+ (a, b) => b[1] - a[1]
2723
+ )[0] ?? ["square", 0];
2724
+ return shape !== "square" && n / total >= 0.7 ? shape : null;
2725
+ }
2726
+ function rotationHint(item, dominant) {
2727
+ if (!dominant) return void 0;
2728
+ const shape = shapeOf(item);
2729
+ if (shape === "square" || shape === dominant) return void 0;
2730
+ return `${shape} while most photos are ${dominant} \u2014 it may be sideways; if the text doesn't read upright, pb_inbox_rotate it`;
2731
+ }
2689
2732
  function retakeHint(item, all) {
2690
2733
  let best;
2691
2734
  for (const other of all) {
@@ -2698,26 +2741,63 @@ function retakeHint(item, all) {
2698
2741
  }
2699
2742
  return best;
2700
2743
  }
2701
- function viewItem(item, all) {
2744
+ function viewItem(item, all, dominant = all ? dominantShape(all) : null) {
2702
2745
  const v = {
2703
2746
  id: item.id,
2704
2747
  name: item.original_name,
2705
2748
  captured_at: item.captured_at,
2706
2749
  size: `${item.width}x${item.height}`,
2707
- status: item.status
2750
+ status: item.status,
2751
+ version: item.mtime
2708
2752
  };
2709
2753
  if (item.recipe_slug) v.recipe_slug = item.recipe_slug;
2710
2754
  if (item.reason) v.reason = item.reason;
2711
2755
  if (all) {
2712
2756
  const r = retakeHint(item, all);
2713
2757
  if (r) v.retake_of = r;
2758
+ const hint = rotationHint(item, dominant);
2759
+ if (hint) v.rotation_hint = hint;
2714
2760
  }
2715
2761
  return v;
2716
2762
  }
2763
+ function viewItems(items, all) {
2764
+ const dominant = dominantShape(all);
2765
+ return items.map((i) => viewItem(i, all, dominant));
2766
+ }
2717
2767
  function nextBatch(ctx, window = 4) {
2718
2768
  const all = ctx.store.allIngest();
2719
2769
  const items = all.filter((i) => i.status === "pending").slice(0, Math.max(1, window));
2720
- return { progress: ctx.store.ingestCounts(), items: items.map((i) => viewItem(i, all)) };
2770
+ return { progress: ctx.store.ingestCounts(), items: viewItems(items, all) };
2771
+ }
2772
+ async function rotateItem(ctx, id, degrees) {
2773
+ const [item] = ctx.store.ingestByIds([id]);
2774
+ if (!item) throw new Error(`no photo #${id}`);
2775
+ const abs = path7.join(ctx.paths.data, item.path);
2776
+ if (!fs10.existsSync(abs)) throw new Error(`photo #${id} is missing on disk (${item.path})`);
2777
+ const heif = /\.(heic|heif)$/i.test(abs);
2778
+ if (item.status === "done" && heif) {
2779
+ throw new Error(
2780
+ `photo #${id} is an archived HEIC; rotating would rename the file ${item.recipe_slug} uses`
2781
+ );
2782
+ }
2783
+ const target = await rotateFile(abs, degrees);
2784
+ const info = await inspectImage(target);
2785
+ const rel = relativeToData(ctx.paths, target);
2786
+ const clash = ctx.store.ingestBySha(info.sha256);
2787
+ if (clash && clash.id !== item.id)
2788
+ throw new Error(`rotated photo #${id} is identical to #${clash.id}`);
2789
+ ctx.store.updateIngest(item.id, {
2790
+ path: rel,
2791
+ sha256: info.sha256,
2792
+ phash: info.phash,
2793
+ width: info.width,
2794
+ height: info.height,
2795
+ bytes: info.bytes,
2796
+ mtime: info.mtime,
2797
+ mime: info.mime
2798
+ });
2799
+ const updated = ctx.store.ingestByIds([id])[0] ?? item;
2800
+ return viewItem(updated, ctx.store.allIngest());
2721
2801
  }
2722
2802
  function resolveItems(ctx, ids, status, reason) {
2723
2803
  const out = { moved: 0, refused: [] };
@@ -3159,7 +3239,7 @@ function routes(ctx, version) {
3159
3239
  });
3160
3240
  return {
3161
3241
  counts: ctx.store.ingestCounts(),
3162
- items: items.map((i) => ({ ...viewItem(i, all), path: i.path }))
3242
+ items: viewItems(items, all).map((v, n) => ({ ...v, path: items[n]?.path ?? "" }))
3163
3243
  };
3164
3244
  }),
3165
3245
  r("POST", /^\/api\/inbox\/upload$/, async (req) => {
@@ -3170,6 +3250,13 @@ function routes(ctx, version) {
3170
3250
  const item = await addUpload(ctx, name, data);
3171
3251
  return { item: item ? viewItem(item) : null, counts: ctx.store.ingestCounts() };
3172
3252
  }),
3253
+ r("POST", /^\/api\/inbox\/rotate$/, async (req) => {
3254
+ const body = await readJson(req);
3255
+ if (typeof body.id !== "number" || ![90, 180, 270].includes(body.degrees ?? 0)) {
3256
+ throw new HttpError(400, "id and degrees (90|180|270, clockwise) required");
3257
+ }
3258
+ return { item: await rotateItem(ctx, body.id, body.degrees) };
3259
+ }),
3173
3260
  r("POST", /^\/api\/inbox\/resolve$/, async (req) => {
3174
3261
  const body = await readJson(req);
3175
3262
  if (!body.ids?.length || !["pending", "skipped", "failed"].includes(body.status ?? "")) {
@@ -3323,8 +3410,9 @@ export {
3323
3410
  imageForModel,
3324
3411
  maybeScanInbox,
3325
3412
  importPaths,
3326
- viewItem,
3413
+ viewItems,
3327
3414
  nextBatch,
3415
+ rotateItem,
3328
3416
  resolveItems,
3329
3417
  planAttach,
3330
3418
  finalizeAttach,
@@ -3338,4 +3426,4 @@ export {
3338
3426
  matchRecipes,
3339
3427
  startUiServer
3340
3428
  };
3341
- //# sourceMappingURL=chunk-2EMC6TJB.js.map
3429
+ //# sourceMappingURL=chunk-QUWSBZ2V.js.map
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-QUWSBZ2V.js";
16
16
 
17
17
  // src/cli.ts
18
18
  import { spawn } from "child_process";
package/dist/server.js CHANGED
@@ -28,12 +28,13 @@ import {
28
28
  removeFromPantry,
29
29
  resolveItem,
30
30
  resolveItems,
31
+ rotateItem,
31
32
  scoreRecipes,
32
33
  searchRecipes,
33
34
  startUiServer,
34
- viewItem,
35
+ viewItems,
35
36
  writeRecipeFile
36
- } from "./chunk-2EMC6TJB.js";
37
+ } from "./chunk-QUWSBZ2V.js";
37
38
 
38
39
  // src/server.ts
39
40
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -136,7 +137,7 @@ import path from "path";
136
137
  import { z as z3 } from "zod";
137
138
  var pb_inbox_image_default = defineTool({
138
139
  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.",
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.",
140
141
  schema: {
141
142
  id: z3.number().int(),
142
143
  max_px: z3.number().int().min(400).max(2400).optional()
@@ -175,7 +176,10 @@ var pb_inbox_list_default = defineTool({
175
176
  },
176
177
  handler: ({ status, limit, offset }, ctx) => {
177
178
  const items = ctx.store.listIngest({ status, limit: limit ?? 20, offset });
178
- return text({ counts: ctx.store.ingestCounts(), items: items.map((i) => viewItem(i)) });
179
+ return text({
180
+ counts: ctx.store.ingestCounts(),
181
+ items: viewItems(items, ctx.store.allIngest())
182
+ });
179
183
  }
180
184
  });
181
185
 
@@ -183,7 +187,7 @@ var pb_inbox_list_default = defineTool({
183
187
  import { z as z5 } from "zod";
184
188
  var pb_inbox_next_default = defineTool({
185
189
  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.",
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.",
187
191
  schema: {
188
192
  window: z5.number().int().min(1).max(12).optional().describe("How many photos to return (default 4)")
189
193
  },
@@ -210,14 +214,26 @@ var pb_inbox_resolve_default = defineTool({
210
214
  }
211
215
  });
212
216
 
213
- // src/mcp/tools/pb_ingest_source.ts
217
+ // src/mcp/tools/pb_inbox_rotate.ts
214
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";
215
231
  var pb_ingest_source_default = defineTool({
216
232
  name: "pb_ingest_source",
217
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.",
218
234
  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()
235
+ paths: z8.array(z8.string()).min(1).describe("Files or folders; relative to the project, absolute, or ~/\u2026"),
236
+ recursive: z8.boolean().optional()
221
237
  },
222
238
  handler: async ({ paths, recursive }, ctx) => {
223
239
  const r = await importPaths(ctx, paths, { recursive });
@@ -234,13 +250,13 @@ var pb_ingest_source_default = defineTool({
234
250
  });
235
251
 
236
252
  // src/mcp/tools/pb_list_tags.ts
237
- import { z as z8 } from "zod";
253
+ import { z as z9 } from "zod";
238
254
  var pb_list_tags_default = defineTool({
239
255
  name: "pb_list_tags",
240
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.",
241
257
  schema: {
242
- category: z8.enum(ALL_CATEGORIES).optional(),
243
- vocabulary: z8.boolean().optional()
258
+ category: z9.enum(ALL_CATEGORIES).optional(),
259
+ vocabulary: z9.boolean().optional()
244
260
  },
245
261
  handler: ({ category, vocabulary }, ctx) => {
246
262
  const used = {};
@@ -259,7 +275,7 @@ var pb_list_tags_default = defineTool({
259
275
  });
260
276
 
261
277
  // src/mcp/tools/pb_merge_ingredients.ts
262
- import { z as z9 } from "zod";
278
+ import { z as z10 } from "zod";
263
279
 
264
280
  // src/ingredients/merge.ts
265
281
  import fs from "fs";
@@ -363,8 +379,8 @@ var pb_merge_ingredients_default = defineTool({
363
379
  name: "pb_merge_ingredients",
364
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.",
365
381
  schema: {
366
- from: z9.string().min(1),
367
- into: z9.string().min(1)
382
+ from: z10.string().min(1),
383
+ into: z10.string().min(1)
368
384
  },
369
385
  handler: ({ from, into }, ctx) => text(mergeIngredients(ctx, from, into))
370
386
  });
@@ -381,7 +397,7 @@ var pb_open_ui_default = defineTool({
381
397
  });
382
398
 
383
399
  // src/mcp/tools/pb_pantry.ts
384
- import { z as z10 } from "zod";
400
+ import { z as z11 } from "zod";
385
401
  var pb_pantry_list = defineTool({
386
402
  name: "pb_pantry_list",
387
403
  description: "What's in the pantry: canonical ingredient names with optional quantity, unit and note.",
@@ -403,12 +419,12 @@ var pb_pantry_add = defineTool({
403
419
  name: "pb_pantry_add",
404
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.",
405
421
  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()
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()
412
428
  })
413
429
  ).min(1)
414
430
  },
@@ -418,8 +434,8 @@ var pb_pantry_remove = defineTool({
418
434
  name: "pb_pantry_remove",
419
435
  description: "Remove items from the pantry by name, or everything with all:true.",
420
436
  schema: {
421
- names: z10.array(z10.string()).optional(),
422
- all: z10.boolean().optional()
437
+ names: z11.array(z11.string()).optional(),
438
+ all: z11.boolean().optional()
423
439
  },
424
440
  handler: ({ names, all }, ctx) => {
425
441
  if (all) return text({ cleared: ctx.store.pantryClear() });
@@ -428,19 +444,19 @@ var pb_pantry_remove = defineTool({
428
444
  });
429
445
 
430
446
  // src/mcp/tools/pb_pantry_match.ts
431
- import { z as z11 } from "zod";
447
+ import { z as z12 } from "zod";
432
448
  var pb_pantry_match_default = defineTool({
433
449
  name: "pb_pantry_match",
434
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).",
435
451
  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()
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()
444
460
  },
445
461
  handler: ({ ingredients, use_pantry, min_coverage, max_missing, limit, ...filters }, ctx) => {
446
462
  const r = matchRecipes(ctx, {
@@ -467,12 +483,12 @@ var pb_pantry_match_default = defineTool({
467
483
  });
468
484
 
469
485
  // src/mcp/tools/pb_providers.ts
470
- import { z as z12 } from "zod";
486
+ import { z as z13 } from "zod";
471
487
  var pb_providers_default = defineTool({
472
488
  name: "pb_providers",
473
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.",
474
490
  schema: {
475
- id: z12.string().optional().describe('e.g. "ucook"; "generic" for anything else')
491
+ id: z13.string().optional().describe('e.g. "ucook"; "generic" for anything else')
476
492
  },
477
493
  handler: ({ id }, ctx) => {
478
494
  if (!id) {
@@ -500,6 +516,7 @@ var pb_providers_default = defineTool({
500
516
  `- Brands stripped from canonical names: ${p.brands.join(", ") || "none"}`,
501
517
  `- Kitchen items treated as equipment: ${Object.keys(p.equipment_terms).join(", ") || "none"}`,
502
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)`,
503
520
  "",
504
521
  "## Tag vocabulary (closed = only these values; open = top values, new ones allowed)",
505
522
  ...Object.entries(summary).map(
@@ -511,13 +528,13 @@ var pb_providers_default = defineTool({
511
528
  });
512
529
 
513
530
  // src/mcp/tools/pb_rebuild.ts
514
- import { z as z13 } from "zod";
531
+ import { z as z14 } from "zod";
515
532
  var pb_rebuild_default = defineTool({
516
533
  name: "pb_rebuild",
517
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.",
518
535
  schema: {
519
- full: z13.boolean().optional(),
520
- reembed: z13.boolean().optional()
536
+ full: z14.boolean().optional(),
537
+ reembed: z14.boolean().optional()
521
538
  },
522
539
  handler: ({ full, reembed }, ctx) => {
523
540
  const reloaded = ctx.refresh(true);
@@ -527,7 +544,7 @@ var pb_rebuild_default = defineTool({
527
544
  });
528
545
 
529
546
  // src/mcp/tools/pb_save_recipe.ts
530
- import { z as z14 } from "zod";
547
+ import { z as z15 } from "zod";
531
548
 
532
549
  // src/files/slug.ts
533
550
  var MAX_SLUG = 60;
@@ -628,10 +645,10 @@ var pb_save_recipe_default = defineTool({
628
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.",
629
646
  schema: {
630
647
  ...recipeInputSchema.shape,
631
- ingest: z14.array(z14.object({ id: z14.number().int(), side: z14.enum(IMAGE_SIDES) })).optional().describe(
648
+ ingest: z15.array(z15.object({ id: z15.number().int(), side: z15.enum(IMAGE_SIDES) })).optional().describe(
632
649
  "Inbox photos of this card, e.g. [{id: 12, side: 'front'}, {id: 13, side: 'back'}]"
633
650
  ),
634
- allow_duplicate: z14.boolean().optional()
651
+ allow_duplicate: z15.boolean().optional()
635
652
  },
636
653
  handler: ({ allow_duplicate, ingest, ...input }, ctx) => {
637
654
  return text(saveRecipe(ctx, input, { allow_duplicate, ingest }));
@@ -639,22 +656,22 @@ var pb_save_recipe_default = defineTool({
639
656
  });
640
657
 
641
658
  // src/mcp/tools/pb_search.ts
642
- import { z as z15 } from "zod";
659
+ import { z as z16 } from "zod";
643
660
  var pb_search_default = defineTool({
644
661
  name: "pb_search",
645
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.",
646
663
  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()
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()
658
675
  },
659
676
  handler: async ({ query, limit, mode, ...filters }, ctx) => {
660
677
  const res = await searchRecipes(ctx, { query, limit, mode, filters });
@@ -695,7 +712,7 @@ var pb_stats_default = defineTool({
695
712
  });
696
713
 
697
714
  // src/mcp/tools/pb_synthesis_context.ts
698
- import { z as z16 } from "zod";
715
+ import { z as z17 } from "zod";
699
716
 
700
717
  // src/synthesis/context.ts
701
718
  var STEP_CHARS = 220;
@@ -829,14 +846,14 @@ var pb_synthesis_context_default = defineTool({
829
846
  name: "pb_synthesis_context",
830
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).",
831
848
  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)")
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)")
840
857
  },
841
858
  handler: async ({ query, slugs, use_pantry, ingredients, n, ...filters }, ctx) => text(await synthesisContext(ctx, { query, slugs, use_pantry, ingredients, n, filters }))
842
859
  });
@@ -858,6 +875,7 @@ var tools = [
858
875
  pb_ingest_source_default,
859
876
  pb_inbox_next_default,
860
877
  pb_inbox_image_default,
878
+ pb_inbox_rotate_default,
861
879
  pb_inbox_resolve_default,
862
880
  pb_inbox_list_default,
863
881
  pb_save_recipe_default,