mcp-marmiton 1.0.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,15 @@ original: half a sachet at factor 0.667 becomes a third of a sachet, never a who
71
71
  one. And scaling never **silently drops** an ingredient by rounding it to zero,
72
72
  which is why small amounts come back as fractions.
73
73
 
74
+ Ranges are read as one quantity: "2 à 3 gousses" doubled reads "4 à 6 gousses",
75
+ and `amount` reports the upper bound, since that is what a cook buys.
76
+
77
+ Below a quarter there is nothing a kitchen can measure, so the amount is clamped
78
+ up and **the line stops holding its share of the recipe**. Half a sachet of
79
+ baking powder against three pots of flour comes out four times too strong that
80
+ way, so the ingredient and the response both say it happened rather than leaving
81
+ you to find out in the oven.
82
+
74
83
  `scale_ingredients` exposes the same logic **without any network request**, so it
75
84
  also works on a recipe pasted from somewhere else.
76
85
 
@@ -157,6 +166,19 @@ and use your judgement.
157
166
 
158
167
  This is an unofficial project, with no affiliation to or endorsement by Marmiton.
159
168
 
169
+ ## Contributing
170
+
171
+ Bugs, questions and ideas all belong in
172
+ [the issue tracker](https://github.com/smeet666/mcp-marmiton/issues). Pull requests
173
+ are welcome; please open an issue first so we can agree on what the right
174
+ answer is before you write it. [CONTRIBUTING.md](CONTRIBUTING.md) has the
175
+ detail, and [SECURITY.md](SECURITY.md) covers anything exploitable.
176
+
177
+ ## Support
178
+
179
+ These servers are free and stay free. If one of them saved you an afternoon,
180
+ you can [buy me a coffee](https://buymeacoffee.com/smeet666).
181
+
160
182
  ## License
161
183
 
162
184
  MIT. See [LICENSE](./LICENSE). The license covers this source code only, not the
@@ -229,6 +251,16 @@ l'originale : un demi-sachet au facteur 0,667 devient un tiers de sachet, jamais
229
251
  sachet entier. Et l'adaptation ne **supprime jamais** un ingrédient en l'arrondissant
230
252
  à zéro, d'où les fractions pour les petites quantités.
231
253
 
254
+ Les fourchettes sont lues comme une seule quantité : « 2 à 3 gousses » doublé
255
+ donne « 4 à 6 gousses », et `amount` renvoie la borne haute, celle que l'on
256
+ achète.
257
+
258
+ Sous un quart, il n'y a plus rien qu'une cuisine puisse mesurer : la quantité est
259
+ alors remontée et **la ligne cesse de tenir sa part de la recette**. Un demi-sachet
260
+ de levure face à trois pots de farine ressort ainsi quatre fois trop dosé, donc
261
+ l'ingrédient et la réponse le disent, plutôt que de vous le laisser découvrir au
262
+ four.
263
+
232
264
  `scale_ingredients` expose la même logique **sans aucune requête réseau**, ce qui
233
265
  permet aussi d'adapter une recette venue d'ailleurs.
234
266
 
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.0.4";
7
+ var PKG_VERSION = "1.2.0";
8
8
  var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
9
9
 
10
10
  // src/config.ts
@@ -646,19 +646,26 @@ var MarmitonClient = class {
646
646
  */
647
647
  async search(query) {
648
648
  const url = buildSearchUrl(query);
649
- const { html, cached } = await this.fetchPage(url);
650
- return { data: parseSearchPage(html, url), cached };
649
+ return this.fetchParsed(url, (html) => parseSearchPage(html, url));
651
650
  }
652
651
  async getRecipe(ref) {
653
652
  const { id, url } = resolveRecipeRef(ref);
654
- const { html, cached } = await this.fetchPage(url);
655
- return { data: parseRecipePage(html, { id, url }), cached };
653
+ return this.fetchParsed(url, (html) => parseRecipePage(html, { id, url }));
656
654
  }
657
- async fetchPage(url) {
655
+ /**
656
+ * Fetch, parse, then cache. In that order: a page that could not be read is
657
+ * never stored, so a bad minute at Marmiton cannot be replayed from memory for
658
+ * the rest of the cache lifetime, leaving the tool unable to recover after
659
+ * the site comes back.
660
+ *
661
+ * The cached value is the parsed result rather than the raw page, which also
662
+ * keeps a few hundred kilobytes of markup per entry out of memory.
663
+ */
664
+ async fetchParsed(url, parse) {
658
665
  const hit = this.cache.get(url);
659
666
  if (hit !== void 0) {
660
667
  this.logger.debug(`cache hit ${url}`);
661
- return { html: hit, cached: true };
668
+ return { data: hit, cached: true };
662
669
  }
663
670
  const html = await fetchHtml(url, {
664
671
  config: this.config,
@@ -666,8 +673,9 @@ var MarmitonClient = class {
666
673
  logger: this.logger,
667
674
  ...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
668
675
  });
669
- this.cache.set(url, html);
670
- return { html, cached: false };
676
+ const data = parse(html);
677
+ this.cache.set(url, data);
678
+ return { data, cached: false };
671
679
  }
672
680
  };
673
681
 
@@ -677,22 +685,22 @@ import { z as z2 } from "zod";
677
685
  // src/recipe/units.ts
678
686
  var UNITS = {
679
687
  // Mass
680
- g: { canonical: "g", kind: "measured" },
681
- gr: { canonical: "g", kind: "measured" },
682
- gramme: { canonical: "g", kind: "measured" },
683
- grammes: { canonical: "g", kind: "measured" },
684
- kg: { canonical: "kg", kind: "measured" },
685
- kilo: { canonical: "kg", kind: "measured" },
686
- kilos: { canonical: "kg", kind: "measured" },
687
- kilogramme: { canonical: "kg", kind: "measured" },
688
- mg: { canonical: "mg", kind: "measured" },
688
+ g: { canonical: "g", kind: "measured", symbol: true },
689
+ gr: { canonical: "g", kind: "measured", symbol: true },
690
+ gramme: { canonical: "g", kind: "measured", symbol: true },
691
+ grammes: { canonical: "g", kind: "measured", symbol: true },
692
+ kg: { canonical: "kg", kind: "measured", symbol: true },
693
+ kilo: { canonical: "kg", kind: "measured", symbol: true },
694
+ kilos: { canonical: "kg", kind: "measured", symbol: true },
695
+ kilogramme: { canonical: "kg", kind: "measured", symbol: true },
696
+ mg: { canonical: "mg", kind: "measured", symbol: true },
689
697
  // Volume
690
- ml: { canonical: "ml", kind: "measured" },
691
- cl: { canonical: "cl", kind: "measured" },
692
- dl: { canonical: "dl", kind: "measured" },
693
- l: { canonical: "l", kind: "measured" },
694
- litre: { canonical: "l", kind: "measured" },
695
- litres: { canonical: "l", kind: "measured" },
698
+ ml: { canonical: "ml", kind: "measured", symbol: true },
699
+ cl: { canonical: "cl", kind: "measured", symbol: true },
700
+ dl: { canonical: "dl", kind: "measured", symbol: true },
701
+ l: { canonical: "l", kind: "measured", symbol: true },
702
+ litre: { canonical: "l", kind: "measured", symbol: true },
703
+ litres: { canonical: "l", kind: "measured", symbol: true },
696
704
  // Spoons and cups: real measures, but only in sensible fractions.
697
705
  "cuillere a soupe": {
698
706
  canonical: "cuill\xE8re \xE0 soupe",
@@ -776,6 +784,12 @@ var DEMOTIONS = {
776
784
  cl: { to: "ml", per: 10 },
777
785
  g: { to: "mg", per: 1e3 }
778
786
  };
787
+ function demoteUnit(unit) {
788
+ const step = DEMOTIONS[unit.canonical];
789
+ if (!step) return null;
790
+ const target = lookupUnit(step.to);
791
+ return target ? { unit: target, per: step.per } : null;
792
+ }
779
793
  function convertToReadableUnit(unit, amount) {
780
794
  if (unit.kind !== "measured" || !Number.isFinite(amount) || amount <= 0) {
781
795
  return { amount, unit };
@@ -793,9 +807,9 @@ function convertToReadableUnit(unit, amount) {
793
807
  return { amount, unit };
794
808
  }
795
809
  function formatUnit(unit, amount) {
810
+ if (unit.symbol) return unit.canonical;
796
811
  if (amount < 2) return unit.canonical;
797
812
  if (unit.plural) return unit.plural;
798
- if (/^[a-z]{1,3}$/.test(unit.canonical)) return unit.canonical;
799
813
  return `${unit.canonical}s`;
800
814
  }
801
815
 
@@ -845,9 +859,18 @@ function parseLeadingQuantity(text) {
845
859
  function parseIngredient(line) {
846
860
  const original = line;
847
861
  const text = line.trim();
848
- const quantity = parseLeadingQuantity(text);
862
+ const range = parseLeadingRange(text);
863
+ const quantity = range ?? parseLeadingQuantity(text);
849
864
  if (!quantity) {
850
- return { original, amount: null, unit: null, unitText: null, item: text };
865
+ return {
866
+ original,
867
+ amount: null,
868
+ amountMax: null,
869
+ rangeSeparator: null,
870
+ unit: null,
871
+ unitText: null,
872
+ item: text
873
+ };
851
874
  }
852
875
  let rest = text.slice(quantity.length).trimStart();
853
876
  let unit = null;
@@ -864,7 +887,31 @@ function parseIngredient(line) {
864
887
  }
865
888
  }
866
889
  const item = rest.replace(/^(?:de\s+la\s+|de\s+l'|d'|de\s+|du\s+|des\s+)/i, "").trim();
867
- return { original, amount: quantity.amount, unit, unitText, item };
890
+ return {
891
+ original,
892
+ amount: quantity.amount,
893
+ amountMax: range?.max ?? null,
894
+ rangeSeparator: range?.separator ?? null,
895
+ unit,
896
+ unitText,
897
+ item
898
+ };
899
+ }
900
+ function parseLeadingRange(text) {
901
+ const low = parseLeadingQuantity(text);
902
+ if (!low) return null;
903
+ const after = text.slice(low.length);
904
+ const separator = /^\s*(à|a|ou|-|–|—|\/)\s*/.exec(after);
905
+ if (!separator) return null;
906
+ if (separator[1] === "/") return null;
907
+ const high = parseLeadingQuantity(after.slice(separator[0].length));
908
+ if (!high || high.amount <= low.amount) return null;
909
+ return {
910
+ amount: low.amount,
911
+ max: high.amount,
912
+ separator: separator[1],
913
+ length: low.length + separator[0].length + high.length
914
+ };
868
915
  }
869
916
  function formatAmount(amount, options = {}) {
870
917
  if (!Number.isFinite(amount)) return "";
@@ -899,17 +946,38 @@ function roundMeasured(value) {
899
946
  if (value >= 1) return roundTo(value, 0.5);
900
947
  return Math.round(value * 100) / 100;
901
948
  }
902
- function roundCountable(value, allowHalves) {
903
- if (value <= 0) return 0;
949
+ var SMALLEST_USABLE_FRACTION = 0.25;
950
+ function roundCountable(value, allowHalves, ceiling) {
951
+ if (value <= 0) return { value: 0, clamped: false };
904
952
  if (value < 1) {
905
- const USABLE_FRACTIONS = [0.25, 1 / 3, 0.5, 2 / 3, 0.75];
906
- let closest = USABLE_FRACTIONS[0];
907
- for (const fraction of USABLE_FRACTIONS) {
908
- if (Math.abs(value - fraction) < Math.abs(value - closest)) closest = fraction;
953
+ const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(
954
+ (candidate) => candidate <= Math.max(ceiling, SMALLEST_USABLE_FRACTION)
955
+ );
956
+ let closest = candidates[0];
957
+ for (const candidate of candidates) {
958
+ if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
909
959
  }
910
- return Math.round(closest * 100) / 100;
960
+ return {
961
+ value: Math.round(closest * 100) / 100,
962
+ clamped: value < SMALLEST_USABLE_FRACTION
963
+ };
911
964
  }
912
- return allowHalves ? roundTo(value, 0.5) : Math.round(value);
965
+ return { value: allowHalves ? roundTo(value, 0.5) : Math.round(value), clamped: false };
966
+ }
967
+ function roundMeasuredInUsableUnit(unit, raw) {
968
+ let current = unit;
969
+ let value = raw;
970
+ while (value > 0 && value < 1) {
971
+ const step = demoteUnit(current);
972
+ if (!step) break;
973
+ value *= step.per;
974
+ current = step.unit;
975
+ }
976
+ const rounded = roundMeasured(value);
977
+ return {
978
+ amount: rounded === 0 && value > 0 ? Number(value.toPrecision(2)) : rounded,
979
+ unit: current
980
+ };
913
981
  }
914
982
  function agreeWithAmount(item, amount) {
915
983
  if (!item) return item;
@@ -927,6 +995,12 @@ function agreeWithAmount(item, amount) {
927
995
  }
928
996
  return words.join(" ");
929
997
  }
998
+ var MUTE_H_WORDS = /^(?:huile|huiles|huitre|huitres|huître|huîtres|herbe|herbes|hysope)\b/i;
999
+ function joinItem(item) {
1000
+ if (!item) return "";
1001
+ const elides = /^[aeiouàâäéèêëîïôöûü]/i.test(item) || MUTE_H_WORDS.test(item);
1002
+ return elides ? ` d'${item}` : ` de ${item}`;
1003
+ }
930
1004
  function scaleIngredient(line, options) {
931
1005
  const { factor } = options;
932
1006
  const parsed = parseIngredient(line);
@@ -937,6 +1011,7 @@ function scaleIngredient(line, options) {
937
1011
  amount: null,
938
1012
  unit: null,
939
1013
  scaling: "unscaled",
1014
+ adjusted: false,
940
1015
  note: "No quantity given; adjust to taste."
941
1016
  };
942
1017
  }
@@ -948,41 +1023,47 @@ function scaleIngredient(line, options) {
948
1023
  amount: parsed.amount,
949
1024
  unit: parsed.unit?.canonical ?? null,
950
1025
  scaling: "unscaled",
1026
+ adjusted: false,
951
1027
  note: "Measure is approximate by nature; adjust to taste."
952
1028
  };
953
1029
  }
954
- const raw = parsed.amount * factor;
955
- let amount;
956
- let scaling;
957
- let unit = parsed.unit;
958
- if (kind === "measured") {
959
- const converted = convertToReadableUnit(parsed.unit, roundMeasured(raw));
960
- amount = converted.amount;
961
- unit = converted.unit;
962
- scaling = "scaled";
963
- } else if (kind === "portioned") {
1030
+ const scaleBound = (source) => {
1031
+ const raw = source * factor;
1032
+ if (kind === "measured") {
1033
+ const converted = roundMeasuredInUsableUnit(parsed.unit, raw);
1034
+ const readable = convertToReadableUnit(converted.unit, converted.amount);
1035
+ return { amount: readable.amount, unit: readable.unit, raw, clamped: false };
1036
+ }
964
1037
  const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? "");
965
- amount = roundCountable(raw, allowHalves);
966
- scaling = "rounded";
967
- } else {
968
- amount = roundCountable(raw, false);
969
- scaling = "rounded";
970
- }
1038
+ const ceiling = factor < 1 ? source : Number.POSITIVE_INFINITY;
1039
+ const rounded = roundCountable(raw, allowHalves, ceiling);
1040
+ return { amount: rounded.value, unit: parsed.unit, raw, clamped: rounded.clamped };
1041
+ };
1042
+ const low = scaleBound(parsed.amount);
1043
+ const high = parsed.amountMax === null ? null : scaleBound(parsed.amountMax);
1044
+ const amount = high?.amount ?? low.amount;
1045
+ const unit = high?.unit ?? low.unit;
1046
+ const scaling = kind === "measured" ? "scaled" : "rounded";
971
1047
  const unitLabel = unit ? ` ${formatUnit(unit, amount)}` : "";
972
- const separator = unit ? " de " : " ";
973
1048
  const itemText = unit ? parsed.item : agreeWithAmount(parsed.item, amount);
974
- const item = itemText ? `${separator}${itemText}` : "";
975
- const amountText = formatAmount(amount, { fractions: kind !== "measured" });
1049
+ const item = unit ? joinItem(parsed.item) : itemText ? ` ${itemText}` : "";
1050
+ const asText = (value) => formatAmount(value, { fractions: kind !== "measured" });
1051
+ 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)}`;
976
1052
  const text = `${amountText}${unitLabel}${item}`.trim();
1053
+ const bounds = high === null ? [low] : [low, high];
1054
+ const adjusted = bounds.some((bound) => Math.abs(bound.raw - bound.amount) > 0.01);
977
1055
  const result = {
978
1056
  original: parsed.original,
979
1057
  text,
980
1058
  amount,
981
1059
  unit: unit?.canonical ?? null,
982
- scaling
1060
+ scaling,
1061
+ adjusted
983
1062
  };
984
- if (scaling === "rounded" && Math.abs(raw - amount) > 0.01) {
985
- result.note = `Rounded from ${formatAmount(Math.round(raw * 100) / 100)}.`;
1063
+ if (bounds.some((bound) => bound.clamped)) {
1064
+ 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.`;
1065
+ } else if (scaling === "rounded" && adjusted) {
1066
+ result.note = `Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`;
986
1067
  }
987
1068
  return result;
988
1069
  }
@@ -997,7 +1078,8 @@ function passthroughIngredients(lines) {
997
1078
  text: parsed.original,
998
1079
  amount: parsed.amount,
999
1080
  unit: parsed.unit?.canonical ?? null,
1000
- scaling: "unscaled"
1081
+ scaling: "unscaled",
1082
+ adjusted: false
1001
1083
  };
1002
1084
  });
1003
1085
  }
@@ -1015,11 +1097,19 @@ var scaledIngredientSchema = z.object({
1015
1097
  scaling: z.enum(["scaled", "rounded", "unscaled"]).describe(
1016
1098
  "'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."
1017
1099
  ),
1018
- note: z.string().optional().describe("Why the line was rounded or left alone.")
1100
+ adjusted: z.boolean().describe(
1101
+ "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."
1102
+ ),
1103
+ note: z.string().optional().describe("Why the line was rounded, clamped or left alone.")
1019
1104
  });
1020
- function ok(structured, text) {
1105
+ function ok(structured, text, notes = []) {
1106
+ const trailer = notes.map((note) => `Note: ${note}`).join("\n");
1107
+ const budget = MAX_TEXT_MIRROR_CHARS - (trailer ? trailer.length + 2 : 0);
1108
+ const body = truncate(text, Math.max(0, budget));
1021
1109
  return {
1022
- content: [{ type: "text", text: truncate(text, MAX_TEXT_MIRROR_CHARS) }],
1110
+ content: [{ type: "text", text: trailer ? `${body}
1111
+
1112
+ ${trailer}` : body }],
1023
1113
  structuredContent: structured
1024
1114
  };
1025
1115
  }
@@ -1111,8 +1201,21 @@ async function runGetRecipe(client, args) {
1111
1201
  notes.push(
1112
1202
  `Quantities rescaled from ${originalCount} to ${args.servings} ${unit} (factor ${Math.round(factor * 100) / 100}).`
1113
1203
  );
1114
- if (ingredients.some((entry) => entry.scaling === "rounded")) {
1115
- notes.push("Some quantities were rounded to remain usable; see the 'scaling' field.");
1204
+ const roundedCount = ingredients.filter(
1205
+ (entry) => entry.scaling === "rounded" && entry.adjusted
1206
+ ).length;
1207
+ if (roundedCount > 0) {
1208
+ notes.push(
1209
+ `${roundedCount} quantity(ies) were rounded to remain usable; see the 'scaling' field.`
1210
+ );
1211
+ }
1212
+ const clampedCount = ingredients.filter(
1213
+ (entry) => /clamped up/i.test(entry.note ?? "")
1214
+ ).length;
1215
+ if (clampedCount > 0) {
1216
+ notes.push(
1217
+ `${clampedCount} quantity(ies) fell below the smallest amount worth measuring and were clamped up, so their proportions no longer match the published recipe.`
1218
+ );
1116
1219
  }
1117
1220
  if (ingredients.some((entry) => entry.scaling === "unscaled")) {
1118
1221
  notes.push("Some ingredients carry no usable quantity and were left as published.");
@@ -1165,14 +1268,20 @@ async function runGetRecipe(client, args) {
1165
1268
  args.servings !== void 0 && factor !== null ? `Pour ${args.servings} ${data.recipeYield.unit ?? "portions"} (recette pr\xE9vue pour ${data.recipeYield.text}).` : `Recette pour ${data.recipeYield.text || "portions non pr\xE9cis\xE9es"}.`,
1166
1269
  times
1167
1270
  ].filter(Boolean).join("\n");
1271
+ const rescaled = args.servings !== void 0 && factor !== null;
1168
1272
  const ingredientLines = ingredients.map((entry) => {
1169
- const flag = entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
1273
+ const flag = rescaled && entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
1170
1274
  return `- ${entry.text}${flag}`;
1171
1275
  }).join("\n");
1172
- return ok(structured, `${header}
1173
-
1174
- Ingr\xE9dients:
1175
- ${ingredientLines}`);
1276
+ const stepLines = data.steps.map((step, index) => `${index + 1}. ${step}`).join("\n");
1277
+ const body = [
1278
+ header,
1279
+ `Ingr\xE9dients:
1280
+ ${ingredientLines}`,
1281
+ stepLines ? `Pr\xE9paration:
1282
+ ${stepLines}` : ""
1283
+ ].filter(Boolean).join("\n\n");
1284
+ return ok(structured, body, notes);
1176
1285
  } catch (error) {
1177
1286
  return toToolError(error);
1178
1287
  }
@@ -1198,15 +1307,26 @@ var scaleIngredientsOutputShape = {
1198
1307
  factor: z3.number(),
1199
1308
  ingredients: z3.array(scaledIngredientSchema),
1200
1309
  scaled_count: z3.number().int(),
1201
- rounded_count: z3.number().int(),
1310
+ rounded_count: z3.number().int().describe(
1311
+ "Lines whose value rounding moved away from the exact product, not lines that could have been rounded."
1312
+ ),
1202
1313
  unscaled_count: z3.number().int(),
1203
1314
  notes: z3.array(z3.string())
1204
1315
  };
1316
+ function formatFactor(factor) {
1317
+ return String(Number(factor.toPrecision(3)));
1318
+ }
1205
1319
  function runScaleIngredients(args) {
1206
1320
  try {
1207
1321
  let factor;
1322
+ const notes = [];
1208
1323
  if (args.factor !== void 0) {
1209
1324
  factor = args.factor;
1325
+ if (args.from_servings !== void 0 || args.to_servings !== void 0) {
1326
+ notes.push(
1327
+ "'factor' was given alongside 'from_servings'/'to_servings'. 'factor' was applied and the pair ignored; send only one of the two to remove the ambiguity."
1328
+ );
1329
+ }
1210
1330
  } else if (args.from_servings !== void 0 && args.to_servings !== void 0) {
1211
1331
  factor = args.to_servings / args.from_servings;
1212
1332
  } else {
@@ -1218,10 +1338,12 @@ function runScaleIngredients(args) {
1218
1338
  const ingredients = scaleIngredients(args.ingredients, { factor });
1219
1339
  const counts = {
1220
1340
  scaled: ingredients.filter((entry) => entry.scaling === "scaled").length,
1221
- rounded: ingredients.filter((entry) => entry.scaling === "rounded").length,
1222
- unscaled: ingredients.filter((entry) => entry.scaling === "unscaled").length
1341
+ // Lines that were actually moved, rather than lines that merely belong to
1342
+ // the roundable category: at a factor of 100 every egg lands whole.
1343
+ rounded: ingredients.filter((entry) => entry.scaling === "rounded" && entry.adjusted).length,
1344
+ unscaled: ingredients.filter((entry) => entry.scaling === "unscaled").length,
1345
+ clamped: ingredients.filter((entry) => /clamped up/i.test(entry.note ?? "")).length
1223
1346
  };
1224
- const notes = [];
1225
1347
  if (counts.rounded > 0) {
1226
1348
  notes.push(
1227
1349
  `${counts.rounded} quantity(ies) were rounded to stay usable, rather than left as fractions.`
@@ -1232,8 +1354,13 @@ function runScaleIngredients(args) {
1232
1354
  `${counts.unscaled} line(s) carry no usable quantity and were returned unchanged; adjust to taste.`
1233
1355
  );
1234
1356
  }
1357
+ if (counts.clamped > 0) {
1358
+ notes.push(
1359
+ `${counts.clamped} quantity(ies) fell below the smallest amount worth measuring and were clamped up, so their proportions no longer match the original recipe.`
1360
+ );
1361
+ }
1235
1362
  const structured = {
1236
- factor: Math.round(factor * 1e3) / 1e3,
1363
+ factor: Number(factor.toPrecision(3)),
1237
1364
  ingredients,
1238
1365
  scaled_count: counts.scaled,
1239
1366
  rounded_count: counts.rounded,
@@ -1244,8 +1371,8 @@ function runScaleIngredients(args) {
1244
1371
  const flag = entry.scaling === "unscaled" ? " (non ajust\xE9)" : "";
1245
1372
  return `- ${entry.text}${flag}`;
1246
1373
  }).join("\n");
1247
- return ok(structured, `Facteur ${Math.round(factor * 100) / 100}:
1248
- ${lines}`);
1374
+ return ok(structured, `Facteur ${formatFactor(factor)}:
1375
+ ${lines}`, notes);
1249
1376
  } catch (error) {
1250
1377
  return toToolError(error);
1251
1378
  }