mcp-marmiton 1.3.0 → 1.5.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/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
  [![CI](https://github.com/smeet666/mcp-marmiton/actions/workflows/ci.yml/badge.svg)](https://github.com/smeet666/mcp-marmiton/actions/workflows/ci.yml)
5
5
  [![license](https://img.shields.io/npm/l/mcp-marmiton.svg)](./LICENSE)
6
6
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-6E56CF)](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.smeet666/mcp-marmiton)
7
+ [![Glama](https://glama.ai/mcp/servers/smeet666/mcp-marmiton/badges/score.svg)](https://glama.ai/mcp/servers/smeet666/mcp-marmiton)
7
8
 
8
9
  An [MCP](https://modelcontextprotocol.io) server for [Marmiton](https://www.marmiton.org),
9
10
  the French recipe site. Search recipes, read their ingredients and steps, and
@@ -40,6 +41,14 @@ claude mcp add marmiton -- npx -y mcp-marmiton
40
41
  }
41
42
  ```
42
43
 
44
+ **Bundle, without npm**
45
+
46
+ Download `mcp-marmiton-<version>.mcpb` from
47
+ [the latest release](https://github.com/smeet666/mcp-marmiton/releases/latest) and open
48
+ it. A client that supports MCP bundles installs it on its own, with no npm and
49
+ no configuration file to edit. The bundle carries its dependencies, so nothing
50
+ is fetched at install time.
51
+
43
52
  ## Tools
44
53
 
45
54
  | Tool | What it does | Key parameters |
@@ -60,20 +69,44 @@ Asking a language model to divide a recipe by 1.5 tends to produce "2.4 eggs" an
60
69
  server does the arithmetic itself and, more importantly, **says what it could not
61
70
  compute**. Every ingredient comes back with a `scaling` flag:
62
71
 
63
- | Flag | Meaning | Example at factor 0.667 |
64
- | ---------- | ------------------------------------------------ | --------------------------------- |
65
- | `scaled` | Multiplied and rounded to a readable value. | `25 cl de lait` → `17 cl de lait` |
66
- | `rounded` | Multiplied, then rounded to something countable. | `3 oeufs` → `2 oeufs` |
67
- | `unscaled` | Left exactly as published. | `1 pincée de sel`, `coriandre` |
72
+ | Flag | Meaning | Example |
73
+ | ---------- | -------------------------------------------------- | ---------------------------------------- |
74
+ | `scaled` | The value is the product itself. | `3 oeufs` ×2 → `6 oeufs` |
75
+ | `rounded` | The value had to be moved to stay usable. | `25 cl de lait` ×0.667 → `17 cl de lait` |
76
+ | `unscaled` | Carries no quantity, so left exactly as published. | `sel`, `coriandre` |
77
+
78
+ A count reaches an exact product as readily as a mass does: one pinch multiplied
79
+ by six is six pinches, and that line is `scaled`. `rounded` is for a value that
80
+ landed somewhere the arithmetic did not put it, a half egg taken to a whole one
81
+ or 133.4 g written as 135 g.
68
82
 
69
83
  Two rules are enforced. Scaling a recipe **down never asks for more** than the
70
84
  original: half a sachet at factor 0.667 becomes a third of a sachet, never a whole
71
85
  one. And scaling never **silently drops** an ingredient by rounding it to zero,
72
86
  which is why small amounts come back as fractions.
73
87
 
88
+ Approximate measures are quantities too. A pinch, a handful, a bouchon or a
89
+ ramequin has the size the cook gives it, and the recipe's proportion lives in how
90
+ many are asked for, so the count is multiplied in whole units: `une pincée de
91
+ bicarbonate de soude` from 6 servings to 25 comes back as `4 pincées de
92
+ bicarbonate de soude`, carrying a note. Nothing is ever
93
+ converted into grams or spoons, where published equivalences span a fourfold
94
+ range; an order of magnitude belongs in a note, never in the quantity.
95
+
74
96
  Ranges are read as one quantity: "2 à 3 gousses" doubled reads "4 à 6 gousses",
75
97
  and `amount` reports the upper bound, since that is what a cook buys.
76
98
 
99
+ A line that writes an article where a digit would go is read as one of the
100
+ measure that follows it: `un bouchon de rhum` scaled sixfold is
101
+ `6 bouchons de rhum`, and the note says which word the figure came from. A noun
102
+ sitting between the amount and the `de` that introduces what is measured names a
103
+ container or a gesture, which is how a measure the vocabulary has never met is
104
+ still read. An article before a bare countable thing, as in `un oignon`, leaves
105
+ the line as published.
106
+
107
+ Each ingredient also carries `adjusted`, which says whether rounding moved the
108
+ number away from the exact product.
109
+
77
110
  Below a quarter there is nothing a kitchen can measure, so the amount is clamped
78
111
  up and **the line stops holding its share of the recipe**. Half a sachet of
79
112
  baking powder against three pots of flour comes out four times too strong that
@@ -220,6 +253,14 @@ claude mcp add marmiton -- npx -y mcp-marmiton
220
253
  }
221
254
  ```
222
255
 
256
+ **Bundle, sans npm**
257
+
258
+ Téléchargez `mcp-marmiton-<version>.mcpb` depuis
259
+ [la dernière release](https://github.com/smeet666/mcp-marmiton/releases/latest) et
260
+ ouvrez-le. Un client compatible avec les bundles MCP l'installe seul, sans npm
261
+ ni fichier de configuration à modifier. Le bundle embarque ses dépendances,
262
+ donc rien n'est téléchargé à l'installation.
263
+
223
264
  ## Outils
224
265
 
225
266
  | Outil | Rôle | Paramètres principaux |
@@ -240,21 +281,46 @@ et « 0,67 pincée de sel », énoncés avec le même aplomb qu'un chiffre juste
240
281
  serveur fait le calcul lui-même et, surtout, **signale ce qu'il n'a pas pu
241
282
  calculer**. Chaque ingrédient porte un indicateur `scaling` :
242
283
 
243
- | Indicateur | Sens | Exemple au facteur 0,667 |
244
- | ---------- | ------------------------------------------------- | --------------------------------- |
245
- | `scaled` | Multiplié et arrondi à une valeur lisible. | `25 cl de lait` → `17 cl de lait` |
246
- | `rounded` | Multiplié, puis arrondi à une valeur dénombrable. | `3 oeufs` → `2 oeufs` |
247
- | `unscaled` | Laissé exactement tel que publié. | `1 pincée de sel`, `coriandre` |
284
+ | Indicateur | Sens | Exemple |
285
+ | ---------- | ---------------------------------------------------- | ---------------------------------------- |
286
+ | `scaled` | La valeur est le produit exact. | `3 oeufs` ×2 → `6 oeufs` |
287
+ | `rounded` | La valeur a dû être déplacée pour rester utilisable. | `25 cl de lait` ×0,667 → `17 cl de lait` |
288
+ | `unscaled` | Ne porte aucune quantité, laissé tel que publié. | `sel`, `coriandre` |
289
+
290
+ Un décompte tombe juste aussi bien qu'une masse : une pincée multipliée par six
291
+ fait six pincées, et cette ligne est `scaled`. `rounded` désigne une valeur posée
292
+ ailleurs que là où le calcul la mettait, un demi-œuf ramené à un œuf entier ou
293
+ 133,4 g écrits 135 g.
248
294
 
249
295
  Deux règles sont garanties. Réduire une recette **ne demande jamais davantage** que
250
296
  l'originale : un demi-sachet au facteur 0,667 devient un tiers de sachet, jamais un
251
297
  sachet entier. Et l'adaptation ne **supprime jamais** un ingrédient en l'arrondissant
252
298
  à zéro, d'où les fractions pour les petites quantités.
253
299
 
300
+ Les mesures approximatives sont des quantités elles aussi. Une pincée, une
301
+ poignée, un bouchon ou un ramequin ont la taille que le cuisinier leur donne,
302
+ et la proportion de la recette tient au nombre demandé : ce nombre est donc
303
+ multiplié, en unités entières. « une pincée de bicarbonate de soude » de 6 à 25
304
+ parts revient en « 4 pincées de bicarbonate de soude », accompagnée d'une
305
+ note. Aucune conversion en grammes ni en cuillères : les
306
+ équivalences publiées varient du simple au quadruple, et un ordre de grandeur a
307
+ sa place dans une note, jamais dans la quantité.
308
+
254
309
  Les fourchettes sont lues comme une seule quantité : « 2 à 3 gousses » doublé
255
310
  donne « 4 à 6 gousses », et `amount` renvoie la borne haute, celle que l'on
256
311
  achète.
257
312
 
313
+ Une ligne qui écrit un article là où irait un chiffre est lue comme une unité de
314
+ la mesure qui suit : « un bouchon de rhum » multiplié par six donne « 6 bouchons
315
+ de rhum », et la note indique de quel mot vient le chiffre. Un nom placé entre la
316
+ quantité et le « de » qui introduit ce qui est mesuré désigne un contenant ou un
317
+ geste, ce qui permet de lire une mesure absente du vocabulaire. Un article devant
318
+ une chose dénombrable seule, comme « un oignon », laisse la ligne telle que
319
+ publiée.
320
+
321
+ Chaque ingrédient porte aussi `adjusted`, qui dit si l'arrondi a déplacé le
322
+ nombre par rapport au produit exact.
323
+
258
324
  Sous un quart, il n'y a plus rien qu'une cuisine puisse mesurer : la quantité est
259
325
  alors remontée et **la ligne cesse de tenir sa part de la recette**. Un demi-sachet
260
326
  de levure face à trois pots de farine ressort ainsi quatre fois trop dosé, donc
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
 
6
6
  // src/version.ts
7
- var PKG_VERSION = "1.3.0";
7
+ var PKG_VERSION = "1.5.0";
8
8
  var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
9
9
 
10
10
  // src/config.ts
@@ -752,7 +752,22 @@ var UNITS = {
752
752
  feuilles: { canonical: "feuille", kind: "portioned" },
753
753
  branche: { canonical: "branche", kind: "portioned" },
754
754
  branches: { canonical: "branche", kind: "portioned" },
755
- // Deliberately imprecise: the number is a figure of speech.
755
+ // Approximate by nature: the size of one is the cook's, the count is the
756
+ // recipe's. See `readPartitiveMeasure` for what puts a word here.
757
+ bouchon: { canonical: "bouchon", kind: "vague" },
758
+ bouchons: { canonical: "bouchon", kind: "vague" },
759
+ larme: { canonical: "larme", kind: "vague" },
760
+ larmes: { canonical: "larme", kind: "vague" },
761
+ doigt: { canonical: "doigt", kind: "vague" },
762
+ doigts: { canonical: "doigt", kind: "vague" },
763
+ nuage: { canonical: "nuage", kind: "vague" },
764
+ nuages: { canonical: "nuage", kind: "vague" },
765
+ louche: { canonical: "louche", kind: "vague" },
766
+ louches: { canonical: "louche", kind: "vague" },
767
+ lichette: { canonical: "lichette", kind: "vague" },
768
+ lichettes: { canonical: "lichette", kind: "vague" },
769
+ pointe: { canonical: "pointe", kind: "vague" },
770
+ pointes: { canonical: "pointe", kind: "vague" },
756
771
  pincee: { canonical: "pinc\xE9e", kind: "vague", plural: "pinc\xE9es" },
757
772
  pincees: { canonical: "pinc\xE9e", kind: "vague", plural: "pinc\xE9es" },
758
773
  trait: { canonical: "trait", kind: "vague" },
@@ -762,7 +777,11 @@ var UNITS = {
762
777
  goutte: { canonical: "goutte", kind: "vague", plural: "gouttes" },
763
778
  gouttes: { canonical: "goutte", kind: "vague", plural: "gouttes" },
764
779
  poignee: { canonical: "poign\xE9e", kind: "vague", plural: "poign\xE9es" },
765
- poignees: { canonical: "poign\xE9e", kind: "vague", plural: "poign\xE9es" }
780
+ poignees: { canonical: "poign\xE9e", kind: "vague", plural: "poign\xE9es" },
781
+ // "noix" carries its own plural mark already.
782
+ noix: { canonical: "noix", kind: "vague", plural: "noix" },
783
+ soupcon: { canonical: "soup\xE7on", kind: "vague", plural: "soup\xE7ons" },
784
+ soupcons: { canonical: "soup\xE7on", kind: "vague", plural: "soup\xE7ons" }
766
785
  };
767
786
  function normalizeUnitKey(text) {
768
787
  return text.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/\./g, " ").replace(/\s+/g, " ").trim();
@@ -771,6 +790,48 @@ var UNIT_KEYS = Object.keys(UNITS).sort((a, b) => b.length - a.length);
771
790
  function lookupUnit(text) {
772
791
  return UNITS[normalizeUnitKey(text)] ?? null;
773
792
  }
793
+ var NOT_A_MEASURE = /* @__PURE__ */ new Set([
794
+ "peu",
795
+ "beaucoup",
796
+ "plus",
797
+ "moins",
798
+ "assez",
799
+ "trop",
800
+ "autant",
801
+ "tant",
802
+ "moitie",
803
+ "quart",
804
+ "tiers",
805
+ "reste",
806
+ "melange",
807
+ "ensemble"
808
+ ]);
809
+ function readPartitiveMeasure(text) {
810
+ const match = new RegExp("^\\s*(\\p{L}+)\\s+(?=(?:de|du|des)\\s|d')", "u").exec(text);
811
+ if (!match) return null;
812
+ const word = match[1];
813
+ if (word.length < 3) return null;
814
+ if (NOT_A_MEASURE.has(normalizeUnitKey(word))) return null;
815
+ if (lookupUnit(word)) return null;
816
+ const canonical = frenchSingular(word);
817
+ return {
818
+ unit: { canonical, kind: "vague", plural: frenchPlural(canonical) },
819
+ rest: text.slice(match[0].length)
820
+ };
821
+ }
822
+ function frenchSingular(word) {
823
+ if (/eaux$/i.test(word)) return word.slice(0, -1);
824
+ if (/aux$/i.test(word)) return `${word.slice(0, -3)}al`;
825
+ if (/[aiou]s$/i.test(word)) return word;
826
+ if (/s$/i.test(word) && word.length > 3) return word.slice(0, -1);
827
+ return word;
828
+ }
829
+ function frenchPlural(word) {
830
+ if (/[sxz]$/i.test(word)) return word;
831
+ if (/eau$/i.test(word)) return `${word}x`;
832
+ if (/al$/i.test(word)) return `${word.slice(0, -2)}aux`;
833
+ return `${word}s`;
834
+ }
774
835
  var PROMOTIONS = {
775
836
  mg: { to: "g", per: 1e3 },
776
837
  g: { to: "kg", per: 1e3 },
@@ -791,21 +852,21 @@ function demoteUnit(unit) {
791
852
  const target = lookupUnit(step.to);
792
853
  return target ? { unit: target, per: step.per } : null;
793
854
  }
794
- function convertToReadableUnit(unit, amount) {
855
+ function readableUnitStep(unit, amount) {
795
856
  if (unit.kind !== "measured" || !Number.isFinite(amount) || amount <= 0) {
796
- return { amount, unit };
857
+ return { unit, ratio: 1 };
797
858
  }
798
859
  const up = PROMOTIONS[unit.canonical];
799
860
  if (up && amount >= up.per) {
800
861
  const target = lookupUnit(up.to);
801
- if (target) return { amount: Math.round(amount / up.per * 100) / 100, unit: target };
862
+ if (target) return { unit: target, ratio: 1 / up.per };
802
863
  }
803
864
  const down = DEMOTIONS[unit.canonical];
804
865
  if (down && amount < 1) {
805
866
  const target = lookupUnit(down.to);
806
- if (target) return { amount: Math.round(amount * down.per * 100) / 100, unit: target };
867
+ if (target) return { unit: target, ratio: down.per };
807
868
  }
808
- return { amount, unit };
869
+ return { unit, ratio: 1 };
809
870
  }
810
871
  function formatUnit(unit, amount) {
811
872
  if (unit.symbol) return unit.canonical;
@@ -857,11 +918,53 @@ function parseLeadingQuantity(text) {
857
918
  }
858
919
  return null;
859
920
  }
921
+ var ARTICLE_AMOUNTS = {
922
+ un: 1,
923
+ une: 1,
924
+ quelques: 3
925
+ };
926
+ function parseLeadingArticle(text) {
927
+ const match = /^\s*(un|une|quelques)\b\s*/i.exec(text);
928
+ if (!match) return null;
929
+ const rest = text.slice(match[0].length);
930
+ if (!matchLeadingUnit(rest, true)) return null;
931
+ const word = match[1];
932
+ return {
933
+ amount: ARTICLE_AMOUNTS[word.toLowerCase()],
934
+ length: match[0].length,
935
+ word
936
+ };
937
+ }
938
+ function matchLeadingUnit(text, partitive = false) {
939
+ const normalized = normalizeUnitKey(text);
940
+ for (const key of UNIT_KEYS) {
941
+ if (normalized !== key && !normalized.startsWith(`${key} `)) continue;
942
+ const unit = lookupUnit(key);
943
+ if (!unit) continue;
944
+ const wordCount = key.split(" ").length;
945
+ const words = text.trim().split(/\s+/);
946
+ return {
947
+ unit,
948
+ unitText: words.slice(0, wordCount).join(" "),
949
+ rest: words.slice(wordCount).join(" ")
950
+ };
951
+ }
952
+ const measure = partitive ? readPartitiveMeasure(text) : null;
953
+ if (measure) {
954
+ return {
955
+ unit: measure.unit,
956
+ unitText: text.trim().split(/\s+/)[0],
957
+ rest: measure.rest
958
+ };
959
+ }
960
+ return null;
961
+ }
860
962
  function parseIngredient(line) {
861
963
  const original = line;
862
964
  const text = line.trim();
863
965
  const range = parseLeadingRange(text);
864
- const quantity = range ?? parseLeadingQuantity(text);
966
+ const article = range ? null : parseLeadingArticle(text);
967
+ const quantity = range ?? parseLeadingQuantity(text) ?? article;
865
968
  if (!quantity) {
866
969
  return {
867
970
  original,
@@ -870,24 +973,16 @@ function parseIngredient(line) {
870
973
  rangeSeparator: null,
871
974
  unit: null,
872
975
  unitText: null,
873
- item: text
976
+ item: text,
977
+ articleWord: null
874
978
  };
875
979
  }
876
980
  let rest = text.slice(quantity.length).trimStart();
877
- let unit = null;
878
- let unitText = null;
879
- const normalizedRest = normalizeUnitKey(rest);
880
- for (const key of UNIT_KEYS) {
881
- if (normalizedRest === key || normalizedRest.startsWith(`${key} `)) {
882
- unit = lookupUnit(key);
883
- const wordCount = key.split(" ").length;
884
- const words = rest.split(/\s+/);
885
- unitText = words.slice(0, wordCount).join(" ");
886
- rest = words.slice(wordCount).join(" ");
887
- break;
888
- }
889
- }
890
- const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").trim();
981
+ const matched = matchLeadingUnit(rest, quantity === article);
982
+ const unit = matched?.unit ?? null;
983
+ const unitText = matched?.unitText ?? null;
984
+ if (matched) rest = matched.rest;
985
+ const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").replace(/^(?:une|un)\s+/i, "").trim();
891
986
  return {
892
987
  original,
893
988
  amount: quantity.amount,
@@ -895,7 +990,8 @@ function parseIngredient(line) {
895
990
  rangeSeparator: range?.separator ?? null,
896
991
  unit,
897
992
  unitText,
898
- item
993
+ item,
994
+ articleWord: quantity === article ? article?.word ?? null : null
899
995
  };
900
996
  }
901
997
  function parseLeadingRange(text) {
@@ -948,6 +1044,7 @@ function roundMeasured(value) {
948
1044
  return Math.round(value * 100) / 100;
949
1045
  }
950
1046
  var SMALLEST_USABLE_FRACTION = 0.25;
1047
+ var EXACT_WITHIN = 0.01;
951
1048
  function roundCountable(value, allowHalves, ceiling) {
952
1049
  if (value <= 0) return { value: 0, clamped: false };
953
1050
  if (value < 1) {
@@ -965,19 +1062,28 @@ function roundCountable(value, allowHalves, ceiling) {
965
1062
  }
966
1063
  return { value: allowHalves ? roundTo(value, 0.5) : Math.round(value), clamped: false };
967
1064
  }
1065
+ function scaleApproximateCount(source, raw, factor) {
1066
+ if (raw <= 0) return 0;
1067
+ const whole = Math.max(1, Math.round(raw));
1068
+ return factor < 1 ? Math.min(whole, source) : Math.max(whole, source);
1069
+ }
968
1070
  function roundMeasuredInUsableUnit(unit, raw) {
969
1071
  let current = unit;
970
1072
  let value = raw;
971
1073
  while (value > 0 && value < 1) {
972
- const step = demoteUnit(current);
973
- if (!step) break;
974
- value *= step.per;
975
- current = step.unit;
1074
+ const step2 = demoteUnit(current);
1075
+ if (!step2) break;
1076
+ value *= step2.per;
1077
+ current = step2.unit;
976
1078
  }
977
1079
  const rounded = roundMeasured(value);
1080
+ const amount = rounded === 0 && value > 0 ? Number(value.toPrecision(2)) : rounded;
1081
+ const step = readableUnitStep(current, amount);
1082
+ if (step.ratio === 1) return { amount, unit: current, exact: value };
978
1083
  return {
979
- amount: rounded === 0 && value > 0 ? Number(value.toPrecision(2)) : rounded,
980
- unit: current
1084
+ amount: Math.round(amount * step.ratio * 100) / 100,
1085
+ unit: step.unit,
1086
+ exact: value * step.ratio
981
1087
  };
982
1088
  }
983
1089
  function agreeWithAmount(item, amount) {
@@ -986,16 +1092,72 @@ function agreeWithAmount(item, amount) {
986
1092
  const head = words[0] ?? "";
987
1093
  if (head.length <= 3) return item;
988
1094
  const wantsPlural = amount >= 2;
989
- const isPlural = /s$/i.test(head);
1095
+ const isPlural = /s$|eaux$|aux$/i.test(head);
990
1096
  if (wantsPlural && !isPlural) {
991
1097
  if (/[sxz]$/i.test(head)) return item;
992
- words[0] = `${head}s`;
1098
+ if (/eau$/i.test(head)) words[0] = `${head}x`;
1099
+ else if (/al$/i.test(head)) words[0] = `${head.slice(0, -2)}aux`;
1100
+ else words[0] = `${head}s`;
993
1101
  } else if (!wantsPlural && isPlural) {
994
- if (/[aiou]s$/i.test(head)) return item;
995
- words[0] = head.slice(0, -1);
1102
+ if (/eaux$/i.test(head)) words[0] = head.slice(0, -1);
1103
+ else if (/aux$/i.test(head)) words[0] = `${head.slice(0, -3)}al`;
1104
+ else if (/[aiou]s$/i.test(head)) return item;
1105
+ else words[0] = head.slice(0, -1);
1106
+ }
1107
+ const last = words.length - 1;
1108
+ if (last > 0) {
1109
+ const adjective = agreeTrailingAdjective(words[last], wantsPlural);
1110
+ if (adjective) words[last] = adjective;
996
1111
  }
997
1112
  return words.join(" ");
998
1113
  }
1114
+ var AGREEABLE_ADJECTIVES = /* @__PURE__ */ new Set([
1115
+ "entier",
1116
+ "entiere",
1117
+ "moyen",
1118
+ "moyenne",
1119
+ "petit",
1120
+ "petite",
1121
+ "grand",
1122
+ "grande",
1123
+ "mur",
1124
+ "mure",
1125
+ "vert",
1126
+ "verte",
1127
+ "rouge",
1128
+ "jaune",
1129
+ "noir",
1130
+ "noire",
1131
+ "blanc",
1132
+ "blanche",
1133
+ "rond",
1134
+ "ronde",
1135
+ "hache",
1136
+ "hachee",
1137
+ "coupe",
1138
+ "coupee",
1139
+ "rape",
1140
+ "rapee",
1141
+ "pele",
1142
+ "pelee",
1143
+ "epluche",
1144
+ "epluchee",
1145
+ "denoyaute",
1146
+ "denoyautee",
1147
+ "emince",
1148
+ "emincee"
1149
+ ]);
1150
+ function foldWord(word) {
1151
+ return word.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "");
1152
+ }
1153
+ function agreeTrailingAdjective(word, wantsPlural) {
1154
+ const folded = foldWord(word);
1155
+ const isPlural = folded.endsWith("s");
1156
+ const singular = isPlural ? word.slice(0, -1) : word;
1157
+ if (!AGREEABLE_ADJECTIVES.has(foldWord(singular))) return null;
1158
+ if (wantsPlural === isPlural) return null;
1159
+ return wantsPlural ? `${word}s` : singular;
1160
+ }
999
1161
  var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
1000
1162
  function joinItem(item) {
1001
1163
  if (!item) return "";
@@ -1017,34 +1179,42 @@ function scaleIngredient(line, options) {
1017
1179
  };
1018
1180
  }
1019
1181
  const kind = parsed.unit?.kind ?? "countable";
1020
- if (kind === "vague") {
1021
- return {
1022
- original: parsed.original,
1023
- text: parsed.original,
1024
- amount: parsed.amount,
1025
- unit: parsed.unit?.canonical ?? null,
1026
- scaling: "unscaled",
1027
- adjusted: false,
1028
- note: "Measure is approximate by nature; adjust to taste."
1029
- };
1030
- }
1031
1182
  const scaleBound = (source) => {
1032
1183
  const raw = source * factor;
1184
+ if (kind === "vague") {
1185
+ return {
1186
+ amount: scaleApproximateCount(source, raw, factor),
1187
+ unit: parsed.unit,
1188
+ exact: raw,
1189
+ raw,
1190
+ clamped: false
1191
+ };
1192
+ }
1033
1193
  if (kind === "measured") {
1034
1194
  const converted = roundMeasuredInUsableUnit(parsed.unit, raw);
1035
- const readable = convertToReadableUnit(converted.unit, converted.amount);
1036
- return { amount: readable.amount, unit: readable.unit, raw, clamped: false };
1195
+ return {
1196
+ amount: converted.amount,
1197
+ unit: converted.unit,
1198
+ exact: converted.exact,
1199
+ raw,
1200
+ clamped: false
1201
+ };
1037
1202
  }
1038
1203
  const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? "");
1039
1204
  const ceiling = factor < 1 ? source : Number.POSITIVE_INFINITY;
1040
1205
  const rounded = roundCountable(raw, allowHalves, ceiling);
1041
- return { amount: rounded.value, unit: parsed.unit, raw, clamped: rounded.clamped };
1206
+ return {
1207
+ amount: rounded.value,
1208
+ unit: parsed.unit,
1209
+ exact: raw,
1210
+ raw,
1211
+ clamped: rounded.clamped
1212
+ };
1042
1213
  };
1043
1214
  const low = scaleBound(parsed.amount);
1044
1215
  const high = parsed.amountMax === null ? null : scaleBound(parsed.amountMax);
1045
1216
  const amount = high?.amount ?? low.amount;
1046
1217
  const unit = high?.unit ?? low.unit;
1047
- const scaling = kind === "measured" ? "scaled" : "rounded";
1048
1218
  const unitLabel = unit ? ` ${formatUnit(unit, amount)}` : "";
1049
1219
  const itemText = unit ? parsed.item : agreeWithAmount(parsed.item, amount);
1050
1220
  const item = unit ? joinItem(parsed.item) : itemText ? ` ${itemText}` : "";
@@ -1052,7 +1222,9 @@ function scaleIngredient(line, options) {
1052
1222
  const amountText = high === null ? asText(low.amount) : parsed.rangeSeparator === "-" || parsed.rangeSeparator === "\u2013" || parsed.rangeSeparator === "\u2014" ? `${asText(low.amount)}${parsed.rangeSeparator}${asText(high.amount)}` : `${asText(low.amount)} ${parsed.rangeSeparator} ${asText(high.amount)}`;
1053
1223
  const text = `${amountText}${unitLabel}${item}`.trim();
1054
1224
  const bounds = high === null ? [low] : [low, high];
1055
- const adjusted = bounds.some((bound) => Math.abs(bound.raw - bound.amount) > 0.01);
1225
+ const adjusted = bounds.some((bound) => Math.abs(bound.exact - bound.amount) > EXACT_WITHIN);
1226
+ const clamped = bounds.some((bound) => bound.clamped);
1227
+ const scaling = adjusted || clamped ? "rounded" : "scaled";
1056
1228
  const result = {
1057
1229
  original: parsed.original,
1058
1230
  text,
@@ -1061,13 +1233,33 @@ function scaleIngredient(line, options) {
1061
1233
  scaling,
1062
1234
  adjusted
1063
1235
  };
1064
- if (bounds.some((bound) => bound.clamped)) {
1236
+ if (kind === "vague") {
1237
+ const one = unit ? formatUnit(unit, 1) : "measure";
1238
+ const many = unit ? formatUnit(unit, 2) : "measures";
1239
+ const sentences = [];
1240
+ if (parsed.articleWord) {
1241
+ sentences.push(`"${parsed.articleWord}" read as ${formatAmount(parsed.amount)}.`);
1242
+ }
1243
+ sentences.push(
1244
+ `${APPROXIMATE_MEASURE_MARKER} the number of ${many} was multiplied, and one ${one} keeps the size the cook gives it.`
1245
+ );
1246
+ if (adjusted) sentences.push(`Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`);
1247
+ result.note = sentences.join(" ");
1248
+ } else if (bounds.some((bound) => bound.clamped)) {
1065
1249
  result.note = `Clamped up to ${formatAmount(SMALLEST_USABLE_FRACTION)} from ${formatAmount(Math.round(low.raw * 1e3) / 1e3)}, the smallest amount worth measuring. This line no longer holds its share of the recipe.`;
1066
- } else if (scaling === "rounded" && adjusted) {
1250
+ } else if (adjusted) {
1067
1251
  result.note = `Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`;
1068
1252
  }
1253
+ if (kind !== "vague" && parsed.articleWord) {
1254
+ const read = `"${parsed.articleWord}" read as ${formatAmount(parsed.amount)}.`;
1255
+ result.note = result.note ? `${read} ${result.note}` : read;
1256
+ }
1069
1257
  return result;
1070
1258
  }
1259
+ var APPROXIMATE_MEASURE_MARKER = "Approximate measure:";
1260
+ function isApproximateMeasure(entry) {
1261
+ return entry.note?.includes(APPROXIMATE_MEASURE_MARKER) ?? false;
1262
+ }
1071
1263
  function scaleIngredients(lines, options) {
1072
1264
  return lines.map((line) => scaleIngredient(line, options));
1073
1265
  }
@@ -1096,17 +1288,22 @@ var scaledIngredientSchema = z.object({
1096
1288
  ),
1097
1289
  unit: z.string().nullable().describe("The unit 'amount' is in, which may differ from the one the recipe used."),
1098
1290
  scaling: z.enum(["scaled", "rounded", "unscaled"]).describe(
1099
- "'scaled' was multiplied cleanly. 'rounded' was multiplied then rounded to something countable. 'unscaled' was left alone, either because it carries no quantity or because the measure is approximate by nature."
1291
+ "'scaled' means the arithmetic came out exact, which a count reaches as readily as a mass: one pinch multiplied by six is six pinches. 'rounded' means the value had to be moved to stay usable, to a whole or half unit or to a step a scale can show. 'unscaled' was left alone, because the line carries no quantity."
1100
1292
  ),
1101
1293
  adjusted: z.boolean().describe(
1102
- "True when rounding moved the value away from the exact product. A line can be 'rounded' and still land exactly, as three eggs doubled land on six."
1294
+ "True when rounding moved the value away from the exact product, which is what makes a line 'rounded' rather than 'scaled'."
1103
1295
  ),
1104
- note: z.string().optional().describe("Why the line was rounded, clamped or left alone.")
1296
+ note: z.string().optional().describe(
1297
+ "Why the line was rounded, clamped or left alone, including when the measure is approximate and only its count was multiplied."
1298
+ )
1105
1299
  });
1300
+ function indentMarkerLines(body) {
1301
+ return body.replace(/^(Note:|Source:)/gm, " $1");
1302
+ }
1106
1303
  function ok(structured, text, notes = []) {
1107
1304
  const trailer = notes.map((note) => `Note: ${note}`).join("\n");
1108
1305
  const budget = MAX_TEXT_MIRROR_CHARS - (trailer ? trailer.length + 2 : 0);
1109
- const body = truncate(text, Math.max(0, budget));
1306
+ const body = truncate(indentMarkerLines(text), Math.max(0, budget));
1110
1307
  return {
1111
1308
  content: [{ type: "text", text: trailer ? `${body}
1112
1309
 
@@ -1129,10 +1326,10 @@ function truncate(text, maxChars) {
1129
1326
  var getRecipeDescription = [
1130
1327
  "Read one Marmiton recipe: ingredients, steps, times, category, rating and nutrition.",
1131
1328
  "Give the id returned by search_recipes, or a marmiton.org recipe URL.",
1132
- "Set 'servings' to rescale the quantities. Each ingredient reports how it was handled: 'scaled' for grams",
1133
- "and millilitres, 'rounded' for countable things such as eggs or spoons, and 'unscaled' for lines with no",
1134
- "quantity or with an approximate one such as a pinch of salt. Trust that flag rather than recomputing:",
1135
- "the point of scaling here is to avoid answers like '2.4 eggs'.",
1329
+ "Set 'servings' to rescale the quantities. Each ingredient reports how it was handled: 'scaled' when the",
1330
+ "arithmetic came out exact, which a count of eggs, spoons or pinches reaches as readily as a mass in grams,",
1331
+ "'rounded' when the value had to be moved to stay usable, and 'unscaled' for lines carrying no quantity at all.",
1332
+ "Trust that flag rather than recomputing: the point of scaling here is to avoid answers like '2.4 eggs'.",
1136
1333
  "Recipes yielding pieces rather than servings are rescaled the same way; check 'yield' to see which.",
1137
1334
  "Always cite 'attribution' when showing a recipe to a user."
1138
1335
  ].join(" ");
@@ -1218,6 +1415,11 @@ async function runGetRecipe(client, args) {
1218
1415
  `${clampedCount} quantity(ies) fell below the smallest amount worth measuring and were clamped up, so their proportions no longer match the published recipe.`
1219
1416
  );
1220
1417
  }
1418
+ if (ingredients.some(isApproximateMeasure)) {
1419
+ notes.push(
1420
+ "Approximate measures such as a pinch or a handful had their count multiplied; the size of one is yours to judge."
1421
+ );
1422
+ }
1221
1423
  if (ingredients.some((entry) => entry.scaling === "unscaled")) {
1222
1424
  notes.push("Some ingredients carry no usable quantity and were left as published.");
1223
1425
  }
@@ -1295,8 +1497,10 @@ var scaleIngredientsDescription = [
1295
1497
  "Give either 'factor' directly, or 'from_servings' and 'to_servings' and the factor is computed.",
1296
1498
  "Works on any French ingredient list, whatever its source, so it also serves a recipe the user pasted in.",
1297
1499
  "Quantities in grams or millilitres are multiplied and rounded to readable values; countable things such as",
1298
- "eggs or spoons are rounded to whole or half units; lines with no quantity, or with an approximate one such",
1299
- "as a pinch, are returned untouched and flagged. Prefer this over doing the arithmetic yourself."
1500
+ "eggs or spoons are rounded to whole or half units; approximate measures such as a pinch or a handful have",
1501
+ "their count multiplied in whole units and stay in their own vocabulary. A line writing an article where a",
1502
+ "digit would go, as in 'un bouchon de rhum' or 'une pinc\xE9e de sel', is read as one of that measure. Lines",
1503
+ "carrying no quantity are returned untouched and flagged. Prefer this over doing the arithmetic yourself."
1300
1504
  ].join(" ");
1301
1505
  var scaleIngredientsInputShape = {
1302
1506
  ingredients: z3.array(z3.string().max(300)).min(1).max(100).describe("Ingredient lines, for example ['200 g de farine', '3 oeufs', 'sel']."),
@@ -1343,7 +1547,8 @@ function runScaleIngredients(args) {
1343
1547
  // the roundable category: at a factor of 100 every egg lands whole.
1344
1548
  rounded: ingredients.filter((entry) => entry.scaling === "rounded" && entry.adjusted).length,
1345
1549
  unscaled: ingredients.filter((entry) => entry.scaling === "unscaled").length,
1346
- clamped: ingredients.filter((entry) => /clamped up/i.test(entry.note ?? "")).length
1550
+ clamped: ingredients.filter((entry) => /clamped up/i.test(entry.note ?? "")).length,
1551
+ approximate: ingredients.filter(isApproximateMeasure).length
1347
1552
  };
1348
1553
  if (counts.rounded > 0) {
1349
1554
  notes.push(
@@ -1355,6 +1560,11 @@ function runScaleIngredients(args) {
1355
1560
  `${counts.unscaled} line(s) carry no usable quantity and were returned unchanged; adjust to taste.`
1356
1561
  );
1357
1562
  }
1563
+ if (counts.approximate > 0) {
1564
+ notes.push(
1565
+ `${counts.approximate} approximate measure(s) such as a pinch or a handful had their count multiplied; the size of one is yours to judge.`
1566
+ );
1567
+ }
1358
1568
  if (counts.clamped > 0) {
1359
1569
  notes.push(
1360
1570
  `${counts.clamped} quantity(ies) fell below the smallest amount worth measuring and were clamped up, so their proportions no longer match the original recipe.`