mcp-marmiton 1.5.0 → 1.6.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 +52 -8
- package/dist/index.js +254 -99
- package/dist/index.js.map +1 -1
- package/dist/marmiton/client.js +1 -1
- package/dist/marmiton/client.js.map +1 -1
- package/package.json +1 -1
- package/server.json +10 -6
package/README.md
CHANGED
|
@@ -81,9 +81,35 @@ landed somewhere the arithmetic did not put it, a half egg taken to a whole one
|
|
|
81
81
|
or 133.4 g written as 135 g.
|
|
82
82
|
|
|
83
83
|
Two rules are enforced. Scaling a recipe **down never asks for more** than the
|
|
84
|
-
original: half a sachet at factor 0.667
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
original: half a sachet at factor 0.667 stays half a sachet, never a whole one.
|
|
85
|
+
And scaling never **silently drops** an ingredient by rounding it to zero, which
|
|
86
|
+
is why small amounts come back as fractions.
|
|
87
|
+
|
|
88
|
+
**A counted thing is divided by what it holds, not by what holds it.** A boîte de
|
|
89
|
+
tomates is poured and the rest kept, a sachet de sucre vanillé is split by eye, a
|
|
90
|
+
feuille de gélatine is cut with scissors, a branche de thym is broken in two: all
|
|
91
|
+
of those land on a half. Half an oeuf would have to be
|
|
92
|
+
beaten and weighed, which is not an amount a recipe asks for, so a count of oeufs,
|
|
93
|
+
jaunes or blancs d'oeufs lands on a whole number. A few things are decided by what
|
|
94
|
+
they are: a clou de girofle and a zeste are counted whole, a pot and a bouteille
|
|
95
|
+
hold enough for a quarter to be a portion, a gousse and a tranche are cut off
|
|
96
|
+
something larger and take a corner off in the same gesture, a blanc de poulet is
|
|
97
|
+
meat and halves, and a douzaine states a number of things rather than a measure
|
|
98
|
+
of them, so `2 douzaines d'escargots` at three quarters comes back as
|
|
99
|
+
`18 escargots`.
|
|
100
|
+
|
|
101
|
+
**A thing counted on its own is divided by the size of one against what a recipe
|
|
102
|
+
puts in.** Une crevette, une moule, une noisette, un grain de poivre, une baie de
|
|
103
|
+
genièvre, un anis étoilé is already a portion: a recipe counts twelve of them
|
|
104
|
+
and a smaller recipe puts one fewer in the pan, so they land on a whole number.
|
|
105
|
+
Un gigot, une baguette, un camembert, un ananas, un oignon, une pastèque, une
|
|
106
|
+
pintade, un poulet, un poireau sits at
|
|
107
|
+
the other end of that comparison, asked for by the one or the two and shared out
|
|
108
|
+
with a knife, so they go as far as the quarter. A cut carved off one of them
|
|
109
|
+
stops at the half, une cuisse and une aile being the portion the knife already
|
|
110
|
+
produced. Un jus stops at the half: half
|
|
111
|
+
the jus of a citron is taken by squeezing half the fruit, and a quarter of one
|
|
112
|
+
has to be poured out and measured back.
|
|
87
113
|
|
|
88
114
|
Approximate measures are quantities too. A pinch, a handful, a bouchon or a
|
|
89
115
|
ramequin has the size the cook gives it, and the recipe's proportion lives in how
|
|
@@ -107,8 +133,10 @@ the line as published.
|
|
|
107
133
|
Each ingredient also carries `adjusted`, which says whether rounding moved the
|
|
108
134
|
number away from the exact product.
|
|
109
135
|
|
|
110
|
-
|
|
111
|
-
|
|
136
|
+
A shrinking line keeps the smallest share still worth measuring: a knife takes an
|
|
137
|
+
oignon to a quarter, a boîte or a sachet goes to a half, an oeuf stops at one.
|
|
138
|
+
Under that floor the amount is clamped up and **the line stops holding its share
|
|
139
|
+
of the recipe**. Half a sachet of
|
|
112
140
|
baking powder against three pots of flour comes out four times too strong that
|
|
113
141
|
way, so the ingredient and the response both say it happened rather than leaving
|
|
114
142
|
you to find out in the oven.
|
|
@@ -293,10 +321,24 @@ ailleurs que là où le calcul la mettait, un demi-œuf ramené à un œuf entie
|
|
|
293
321
|
133,4 g écrits 135 g.
|
|
294
322
|
|
|
295
323
|
Deux règles sont garanties. Réduire une recette **ne demande jamais davantage** que
|
|
296
|
-
l'originale : un demi-sachet au facteur 0,667
|
|
324
|
+
l'originale : un demi-sachet au facteur 0,667 reste un demi-sachet, jamais un
|
|
297
325
|
sachet entier. Et l'adaptation ne **supprime jamais** un ingrédient en l'arrondissant
|
|
298
326
|
à zéro, d'où les fractions pour les petites quantités.
|
|
299
327
|
|
|
328
|
+
**Un dénombrable se divise selon son contenu, pas selon son emballage.** Une boîte
|
|
329
|
+
de tomates se verse et le reste se garde, un sachet de sucre vanillé se partage à
|
|
330
|
+
l'œil, une feuille de gélatine se coupe aux ciseaux, une branche de thym se casse
|
|
331
|
+
en deux : tous tombent sur une demie. Un demi-œuf, lui,
|
|
332
|
+
demanderait de le battre et de le peser, ce qu'aucune recette ne demande : un
|
|
333
|
+
nombre d'œufs, de jaunes ou de blancs d'œufs tombe sur un entier. Quelques cas se
|
|
334
|
+
décident sur la nature de la chose : un clou de girofle et un zeste se comptent
|
|
335
|
+
entiers, un pot et une bouteille en contiennent assez pour qu'un quart soit une
|
|
336
|
+
portion, une gousse et une tranche sont déjà taillées dans plus grand et la
|
|
337
|
+
planche leur reprend un coin du même geste, un blanc de poulet est une viande et
|
|
338
|
+
se coupe en deux, et une douzaine
|
|
339
|
+
énonce un nombre de choses plutôt qu'une mesure, si bien que
|
|
340
|
+
« 2 douzaines d'escargots » réduites d'un quart reviennent en « 18 escargots ».
|
|
341
|
+
|
|
300
342
|
Les mesures approximatives sont des quantités elles aussi. Une pincée, une
|
|
301
343
|
poignée, un bouchon ou un ramequin ont la taille que le cuisinier leur donne,
|
|
302
344
|
et la proportion de la recette tient au nombre demandé : ce nombre est donc
|
|
@@ -321,8 +363,10 @@ publiée.
|
|
|
321
363
|
Chaque ingrédient porte aussi `adjusted`, qui dit si l'arrondi a déplacé le
|
|
322
364
|
nombre par rapport au produit exact.
|
|
323
365
|
|
|
324
|
-
|
|
325
|
-
|
|
366
|
+
Une ligne qui rétrécit garde la plus petite part qui vaille encore la peine : le
|
|
367
|
+
couteau mène un oignon au quart, une boîte ou un sachet s'arrêtent à la demie, un
|
|
368
|
+
œuf s'arrête à l'unité. Sous ce plancher, la quantité est remontée et **la ligne
|
|
369
|
+
cesse de tenir sa part de la recette**. Un demi-sachet
|
|
326
370
|
de levure face à trois pots de farine ressort ainsi quatre fois trop dosé, donc
|
|
327
371
|
l'ingrédient et la réponse le disent, plutôt que de vous le laisser découvrir au
|
|
328
372
|
four.
|
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.
|
|
7
|
+
var PKG_VERSION = "1.6.0";
|
|
8
8
|
var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
|
|
9
9
|
|
|
10
10
|
// src/config.ts
|
|
@@ -111,7 +111,7 @@ function createLogger(level) {
|
|
|
111
111
|
|
|
112
112
|
// src/server.ts
|
|
113
113
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
114
|
-
import { z as
|
|
114
|
+
import { z as z6 } from "zod";
|
|
115
115
|
|
|
116
116
|
// src/marmiton/cache.ts
|
|
117
117
|
var TtlLruCache = class {
|
|
@@ -681,7 +681,7 @@ var MarmitonClient = class {
|
|
|
681
681
|
};
|
|
682
682
|
|
|
683
683
|
// src/tools/getRecipe.ts
|
|
684
|
-
import { z as
|
|
684
|
+
import { z as z3 } from "zod";
|
|
685
685
|
|
|
686
686
|
// src/recipe/units.ts
|
|
687
687
|
var UNITS = {
|
|
@@ -868,6 +868,14 @@ function readableUnitStep(unit, amount) {
|
|
|
868
868
|
}
|
|
869
869
|
return { unit, ratio: 1 };
|
|
870
870
|
}
|
|
871
|
+
function unitDivisibility(unit) {
|
|
872
|
+
if (unit.kind === "vague") return "whole";
|
|
873
|
+
return QUARTERED_MEASURE.test(unit.canonical) ? "quarter" : "half";
|
|
874
|
+
}
|
|
875
|
+
var QUARTERED_MEASURE = /\b(pots?|bouteilles?|gousses?|tranches?)\b/;
|
|
876
|
+
function isSpoonMeasure(unit) {
|
|
877
|
+
return /^(cuillère à soupe|cuillère à café|verre|tasse|bol)$/.test(unit.canonical);
|
|
878
|
+
}
|
|
871
879
|
function formatUnit(unit, amount) {
|
|
872
880
|
if (unit.symbol) return unit.canonical;
|
|
873
881
|
if (amount < 2) return unit.canonical;
|
|
@@ -927,7 +935,7 @@ function parseLeadingArticle(text) {
|
|
|
927
935
|
const match = /^\s*(un|une|quelques)\b\s*/i.exec(text);
|
|
928
936
|
if (!match) return null;
|
|
929
937
|
const rest = text.slice(match[0].length);
|
|
930
|
-
if (!matchLeadingUnit(rest, true)) return null;
|
|
938
|
+
if (!matchLeadingUnit(rest, true) && !readCountMultiplier(rest)) return null;
|
|
931
939
|
const word = match[1];
|
|
932
940
|
return {
|
|
933
941
|
amount: ARTICLE_AMOUNTS[word.toLowerCase()],
|
|
@@ -935,6 +943,17 @@ function parseLeadingArticle(text) {
|
|
|
935
943
|
word
|
|
936
944
|
};
|
|
937
945
|
}
|
|
946
|
+
var COUNT_MULTIPLIERS = {
|
|
947
|
+
douzaine: 12,
|
|
948
|
+
douzaines: 12
|
|
949
|
+
};
|
|
950
|
+
function readCountMultiplier(text) {
|
|
951
|
+
const match = new RegExp("^\\s*(\\p{L}+)\\s+", "u").exec(text);
|
|
952
|
+
if (!match) return null;
|
|
953
|
+
const times = COUNT_MULTIPLIERS[normalizeUnitKey(match[1])];
|
|
954
|
+
if (times === void 0) return null;
|
|
955
|
+
return { times, rest: text.slice(match[0].length) };
|
|
956
|
+
}
|
|
938
957
|
function matchLeadingUnit(text, partitive = false) {
|
|
939
958
|
const normalized = normalizeUnitKey(text);
|
|
940
959
|
for (const key of UNIT_KEYS) {
|
|
@@ -974,10 +993,14 @@ function parseIngredient(line) {
|
|
|
974
993
|
unit: null,
|
|
975
994
|
unitText: null,
|
|
976
995
|
item: text,
|
|
977
|
-
articleWord: null
|
|
996
|
+
articleWord: null,
|
|
997
|
+
countMultiplier: null
|
|
978
998
|
};
|
|
979
999
|
}
|
|
980
1000
|
let rest = text.slice(quantity.length).trimStart();
|
|
1001
|
+
const multiplier = readCountMultiplier(rest);
|
|
1002
|
+
if (multiplier) rest = multiplier.rest;
|
|
1003
|
+
const times = multiplier?.times ?? 1;
|
|
981
1004
|
const matched = matchLeadingUnit(rest, quantity === article);
|
|
982
1005
|
const unit = matched?.unit ?? null;
|
|
983
1006
|
const unitText = matched?.unitText ?? null;
|
|
@@ -985,13 +1008,14 @@ function parseIngredient(line) {
|
|
|
985
1008
|
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();
|
|
986
1009
|
return {
|
|
987
1010
|
original,
|
|
988
|
-
amount: quantity.amount,
|
|
989
|
-
amountMax: range
|
|
1011
|
+
amount: quantity.amount * times,
|
|
1012
|
+
amountMax: range === null ? null : range.max * times,
|
|
990
1013
|
rangeSeparator: range?.separator ?? null,
|
|
991
1014
|
unit,
|
|
992
1015
|
unitText,
|
|
993
1016
|
item,
|
|
994
|
-
articleWord: quantity === article ? article?.word ?? null : null
|
|
1017
|
+
articleWord: quantity === article ? article?.word ?? null : null,
|
|
1018
|
+
countMultiplier: multiplier?.times ?? null
|
|
995
1019
|
};
|
|
996
1020
|
}
|
|
997
1021
|
function parseLeadingRange(text) {
|
|
@@ -1044,8 +1068,64 @@ function roundMeasured(value) {
|
|
|
1044
1068
|
return Math.round(value * 100) / 100;
|
|
1045
1069
|
}
|
|
1046
1070
|
var SMALLEST_USABLE_FRACTION = 0.25;
|
|
1071
|
+
var SMALLEST_USABLE = {
|
|
1072
|
+
whole: 1,
|
|
1073
|
+
half: 0.5,
|
|
1074
|
+
quarter: 0.25
|
|
1075
|
+
};
|
|
1076
|
+
function isHalfStep(value) {
|
|
1077
|
+
return Math.abs(value * 2 - Math.round(value * 2)) < 1e-9;
|
|
1078
|
+
}
|
|
1079
|
+
var PORTION_SIZED_ITEM = /\b(crevettes?|gambas|langoustines?|moules?|noisettes?|grains?|genievres?|anis)\b/;
|
|
1080
|
+
var QUARTERED_ITEM = /\b(oignons?|echalotes?|pommes? de terre|pommes?|poires?|carottes?|citrons?|oranges?|tomates?|concombres?|courgettes?|aubergines?|courges?|potirons?|choux?|melons?|poivrons?|betteraves?|navets?|panais|poireaux?|bananes?|mangues?|avocats?|pasteques?|gigots?|baguettes?|camemberts?|fromages?|chevres?|chorizos?|reblochons?|buches?|ananas|peches?|abricots?|laits?|poulets?|pintades?|rotis?)\b/;
|
|
1081
|
+
var HALVED_CUT = /\b(cuisses?|ailes?|pilons?|escalopes?|magrets?)\b/;
|
|
1082
|
+
var HALVED_ITEM = /\bjus\b/;
|
|
1083
|
+
var WHOLE_ITEM = /\b(oeufs?|jaunes?|clous?|zestes?)\b/;
|
|
1084
|
+
function blancDivisibility(key) {
|
|
1085
|
+
if (!/\bblancs? de? /.test(key)) return null;
|
|
1086
|
+
return /\bblancs? de? oeufs?\b/.test(key) ? "whole" : "half";
|
|
1087
|
+
}
|
|
1088
|
+
function articleValue(parsed) {
|
|
1089
|
+
return parsed.amount === null ? 0 : parsed.amount / (parsed.countMultiplier ?? 1);
|
|
1090
|
+
}
|
|
1091
|
+
function divisibilityOf(unit, item) {
|
|
1092
|
+
if (unit) return unitDivisibility(unit);
|
|
1093
|
+
const key = item.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/œ/g, "oe").replace(/['’]/g, " ");
|
|
1094
|
+
const blanc = blancDivisibility(key);
|
|
1095
|
+
if (blanc) return blanc;
|
|
1096
|
+
if (WHOLE_ITEM.test(key)) return "whole";
|
|
1097
|
+
if (PORTION_SIZED_ITEM.test(key)) return "whole";
|
|
1098
|
+
if (HALVED_ITEM.test(key)) return "half";
|
|
1099
|
+
if (HALVED_CUT.test(key)) return "half";
|
|
1100
|
+
if (QUARTERED_MEASURE.test(key)) return "quarter";
|
|
1101
|
+
return QUARTERED_ITEM.test(key) ? "quarter" : "half";
|
|
1102
|
+
}
|
|
1047
1103
|
var EXACT_WITHIN = 0.01;
|
|
1048
|
-
function roundCountable(value,
|
|
1104
|
+
function roundCountable(value, divisibility, ceiling) {
|
|
1105
|
+
if (value <= 0) return { value: 0, clamped: false };
|
|
1106
|
+
const floor = SMALLEST_USABLE[divisibility];
|
|
1107
|
+
if (divisibility !== "whole" && value >= floor && isHalfStep(value)) {
|
|
1108
|
+
return { value, clamped: false };
|
|
1109
|
+
}
|
|
1110
|
+
if (divisibility === "whole") {
|
|
1111
|
+
if (value < 0.5) return { value: floor, clamped: true };
|
|
1112
|
+
return { value: Math.round(value), clamped: false };
|
|
1113
|
+
}
|
|
1114
|
+
if (value < floor) return { value: floor, clamped: true };
|
|
1115
|
+
if (value < 1) {
|
|
1116
|
+
const steps = divisibility === "quarter" ? [0.25, 1 / 3, 0.5, 2 / 3, 0.75, 1] : [0.5, 1];
|
|
1117
|
+
const candidates = steps.filter(
|
|
1118
|
+
(candidate) => candidate >= floor && candidate <= Math.max(ceiling, floor)
|
|
1119
|
+
);
|
|
1120
|
+
let closest = candidates[0];
|
|
1121
|
+
for (const candidate of candidates) {
|
|
1122
|
+
if (Math.abs(value - candidate) < Math.abs(value - closest)) closest = candidate;
|
|
1123
|
+
}
|
|
1124
|
+
return { value: Math.round(closest * 100) / 100, clamped: false };
|
|
1125
|
+
}
|
|
1126
|
+
return { value: Math.round(value), clamped: false };
|
|
1127
|
+
}
|
|
1128
|
+
function roundSpoon(value, ceiling) {
|
|
1049
1129
|
if (value <= 0) return { value: 0, clamped: false };
|
|
1050
1130
|
if (value < 1) {
|
|
1051
1131
|
const candidates = [SMALLEST_USABLE_FRACTION, 1 / 3, 0.5, 2 / 3, 0.75, 1].filter(
|
|
@@ -1060,7 +1140,7 @@ function roundCountable(value, allowHalves, ceiling) {
|
|
|
1060
1140
|
clamped: value < SMALLEST_USABLE_FRACTION
|
|
1061
1141
|
};
|
|
1062
1142
|
}
|
|
1063
|
-
return { value:
|
|
1143
|
+
return { value: roundTo(value, 0.5), clamped: false };
|
|
1064
1144
|
}
|
|
1065
1145
|
function scaleApproximateCount(source, raw, factor) {
|
|
1066
1146
|
if (raw <= 0) return 0;
|
|
@@ -1094,15 +1174,15 @@ function agreeWithAmount(item, amount) {
|
|
|
1094
1174
|
const wantsPlural = amount >= 2;
|
|
1095
1175
|
const isPlural = /s$|eaux$|aux$/i.test(head);
|
|
1096
1176
|
if (wantsPlural && !isPlural) {
|
|
1097
|
-
if (/[sxz]$/i.test(head))
|
|
1098
|
-
if (/eau$/i.test(head)) words[0] = `${head}x`;
|
|
1177
|
+
if (/[sxz]$/i.test(head)) {
|
|
1178
|
+
} else if (/eau$/i.test(head)) words[0] = `${head}x`;
|
|
1099
1179
|
else if (/al$/i.test(head)) words[0] = `${head.slice(0, -2)}aux`;
|
|
1100
1180
|
else words[0] = `${head}s`;
|
|
1101
1181
|
} else if (!wantsPlural && isPlural) {
|
|
1102
1182
|
if (/eaux$/i.test(head)) words[0] = head.slice(0, -1);
|
|
1103
1183
|
else if (/aux$/i.test(head)) words[0] = `${head.slice(0, -3)}al`;
|
|
1104
|
-
else if (
|
|
1105
|
-
else words[0] = head.slice(0, -1);
|
|
1184
|
+
else if (INVARIABLE_NOUN.has(foldWord(head))) {
|
|
1185
|
+
} else words[0] = head.slice(0, -1);
|
|
1106
1186
|
}
|
|
1107
1187
|
const last = words.length - 1;
|
|
1108
1188
|
if (last > 0) {
|
|
@@ -1111,9 +1191,31 @@ function agreeWithAmount(item, amount) {
|
|
|
1111
1191
|
}
|
|
1112
1192
|
return words.join(" ");
|
|
1113
1193
|
}
|
|
1194
|
+
var INVARIABLE_NOUN = /* @__PURE__ */ new Set([
|
|
1195
|
+
"ananas",
|
|
1196
|
+
"anis",
|
|
1197
|
+
"brebis",
|
|
1198
|
+
"cassis",
|
|
1199
|
+
"colis",
|
|
1200
|
+
"coulis",
|
|
1201
|
+
"couscous",
|
|
1202
|
+
"gambas",
|
|
1203
|
+
"houmous",
|
|
1204
|
+
"jus",
|
|
1205
|
+
"mais",
|
|
1206
|
+
"pastis",
|
|
1207
|
+
"pois",
|
|
1208
|
+
"radis",
|
|
1209
|
+
"ris",
|
|
1210
|
+
"souris",
|
|
1211
|
+
"tamis",
|
|
1212
|
+
"tapas"
|
|
1213
|
+
]);
|
|
1114
1214
|
var AGREEABLE_ADJECTIVES = /* @__PURE__ */ new Set([
|
|
1115
1215
|
"entier",
|
|
1116
1216
|
"entiere",
|
|
1217
|
+
"etoile",
|
|
1218
|
+
"etoilee",
|
|
1117
1219
|
"moyen",
|
|
1118
1220
|
"moyenne",
|
|
1119
1221
|
"petit",
|
|
@@ -1200,9 +1302,8 @@ function scaleIngredient(line, options) {
|
|
|
1200
1302
|
clamped: false
|
|
1201
1303
|
};
|
|
1202
1304
|
}
|
|
1203
|
-
const allowHalves = /cuillère|verre|tasse|bol/.test(parsed.unit?.canonical ?? "");
|
|
1204
1305
|
const ceiling = factor < 1 ? source : Number.POSITIVE_INFINITY;
|
|
1205
|
-
const rounded = roundCountable(raw,
|
|
1306
|
+
const rounded = parsed.unit && isSpoonMeasure(parsed.unit) ? roundSpoon(raw, ceiling) : roundCountable(raw, divisibilityOf(parsed.unit, parsed.item), ceiling);
|
|
1206
1307
|
return {
|
|
1207
1308
|
amount: rounded.value,
|
|
1208
1309
|
unit: parsed.unit,
|
|
@@ -1238,20 +1339,21 @@ function scaleIngredient(line, options) {
|
|
|
1238
1339
|
const many = unit ? formatUnit(unit, 2) : "measures";
|
|
1239
1340
|
const sentences = [];
|
|
1240
1341
|
if (parsed.articleWord) {
|
|
1241
|
-
sentences.push(`"${parsed.articleWord}" read as ${formatAmount(parsed
|
|
1342
|
+
sentences.push(`"${parsed.articleWord}" read as ${formatAmount(articleValue(parsed))}.`);
|
|
1242
1343
|
}
|
|
1243
1344
|
sentences.push(
|
|
1244
1345
|
`${APPROXIMATE_MEASURE_MARKER} the number of ${many} was multiplied, and one ${one} keeps the size the cook gives it.`
|
|
1245
1346
|
);
|
|
1246
1347
|
if (adjusted) sentences.push(`Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`);
|
|
1247
1348
|
result.note = sentences.join(" ");
|
|
1248
|
-
} else if (
|
|
1249
|
-
|
|
1349
|
+
} else if (clamped) {
|
|
1350
|
+
const floored = bounds.find((bound) => bound.clamped);
|
|
1351
|
+
result.note = `Clamped up to ${formatAmount(floored.amount)} from ${formatAmount(Math.round(floored.raw * 1e3) / 1e3)}, the smallest amount worth measuring. This line no longer holds its share of the recipe.`;
|
|
1250
1352
|
} else if (adjusted) {
|
|
1251
1353
|
result.note = `Rounded from ${formatAmount(Math.round(low.raw * 100) / 100)}.`;
|
|
1252
1354
|
}
|
|
1253
1355
|
if (kind !== "vague" && parsed.articleWord) {
|
|
1254
|
-
const read = `"${parsed.articleWord}" read as ${formatAmount(parsed
|
|
1356
|
+
const read = `"${parsed.articleWord}" read as ${formatAmount(articleValue(parsed))}.`;
|
|
1255
1357
|
result.note = result.note ? `${read} ${result.note}` : read;
|
|
1256
1358
|
}
|
|
1257
1359
|
return result;
|
|
@@ -1277,23 +1379,74 @@ function passthroughIngredients(lines) {
|
|
|
1277
1379
|
});
|
|
1278
1380
|
}
|
|
1279
1381
|
|
|
1280
|
-
// src/tools/
|
|
1382
|
+
// src/tools/arguments.ts
|
|
1281
1383
|
import { z } from "zod";
|
|
1384
|
+
var INVALID_INPUT = "invalid_input";
|
|
1385
|
+
function strictInput(shape) {
|
|
1386
|
+
const declared = Object.keys(shape);
|
|
1387
|
+
const errorMap = (issue, ctx) => issue.code === z.ZodIssueCode.unrecognized_keys ? { message: unknownArgumentMessage(issue.keys, declared) } : { message: ctx.defaultError };
|
|
1388
|
+
return z.strictObject(shape, { errorMap });
|
|
1389
|
+
}
|
|
1390
|
+
function unknownArgumentMessage(keys, declared) {
|
|
1391
|
+
const named = keys.map((key) => {
|
|
1392
|
+
const near = nearestArgument(key, declared);
|
|
1393
|
+
return near ? `'${key}' (did you mean '${near}'?)` : `'${key}'`;
|
|
1394
|
+
}).join(", ");
|
|
1395
|
+
return `[${INVALID_INPUT}] Unknown ${keys.length > 1 ? "arguments" : "argument"} ${named}. This tool takes: ${declared.join(", ")}.`;
|
|
1396
|
+
}
|
|
1397
|
+
function nearestArgument(key, declared) {
|
|
1398
|
+
const flatten2 = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1399
|
+
const flat = flatten2(key);
|
|
1400
|
+
if (flat.length === 0) return void 0;
|
|
1401
|
+
const sameName = declared.find((name) => flatten2(name) === flat);
|
|
1402
|
+
if (sameName) return sameName;
|
|
1403
|
+
const overlapping = declared.find((name) => {
|
|
1404
|
+
const other = flatten2(name);
|
|
1405
|
+
const [shorter, longer] = other.length < flat.length ? [other, flat] : [flat, other];
|
|
1406
|
+
return shorter.length >= 3 && (longer.startsWith(shorter) || longer.endsWith(shorter));
|
|
1407
|
+
});
|
|
1408
|
+
if (overlapping) return overlapping;
|
|
1409
|
+
let closest;
|
|
1410
|
+
let shortest = Number.POSITIVE_INFINITY;
|
|
1411
|
+
for (const name of declared) {
|
|
1412
|
+
const distance = editDistance(flat, flatten2(name));
|
|
1413
|
+
if (distance < shortest) {
|
|
1414
|
+
shortest = distance;
|
|
1415
|
+
closest = name;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return shortest <= Math.max(1, Math.floor(flat.length / 3)) ? closest : void 0;
|
|
1419
|
+
}
|
|
1420
|
+
function editDistance(left, right) {
|
|
1421
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
1422
|
+
for (let row = 1; row <= left.length; row += 1) {
|
|
1423
|
+
const current = [row];
|
|
1424
|
+
for (let column = 1; column <= right.length; column += 1) {
|
|
1425
|
+
const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
|
|
1426
|
+
current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
|
|
1427
|
+
}
|
|
1428
|
+
previous = current;
|
|
1429
|
+
}
|
|
1430
|
+
return previous[right.length];
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// src/tools/shared.ts
|
|
1434
|
+
import { z as z2 } from "zod";
|
|
1282
1435
|
var MAX_TEXT_MIRROR_CHARS = 2e3;
|
|
1283
|
-
var scaledIngredientSchema =
|
|
1284
|
-
original:
|
|
1285
|
-
text:
|
|
1286
|
-
amount:
|
|
1436
|
+
var scaledIngredientSchema = z2.object({
|
|
1437
|
+
original: z2.string().describe("The ingredient line exactly as Marmiton publishes it."),
|
|
1438
|
+
text: z2.string().describe("The line after scaling, identical to 'original' when unscaled."),
|
|
1439
|
+
amount: z2.number().nullable().describe(
|
|
1287
1440
|
"The scaled quantity, expressed in 'unit'. Read the two together: a large result is moved to a bigger unit, so 200 g scaled tenfold reads as 2 kg, and the bare number shrinks while the quantity grows."
|
|
1288
1441
|
),
|
|
1289
|
-
unit:
|
|
1290
|
-
scaling:
|
|
1442
|
+
unit: z2.string().nullable().describe("The unit 'amount' is in, which may differ from the one the recipe used."),
|
|
1443
|
+
scaling: z2.enum(["scaled", "rounded", "unscaled"]).describe(
|
|
1291
1444
|
"'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."
|
|
1292
1445
|
),
|
|
1293
|
-
adjusted:
|
|
1446
|
+
adjusted: z2.boolean().describe(
|
|
1294
1447
|
"True when rounding moved the value away from the exact product, which is what makes a line 'rounded' rather than 'scaled'."
|
|
1295
1448
|
),
|
|
1296
|
-
note:
|
|
1449
|
+
note: z2.string().optional().describe(
|
|
1297
1450
|
"Why the line was rounded, clamped or left alone, including when the measure is approximate and only its count was multiplied."
|
|
1298
1451
|
)
|
|
1299
1452
|
});
|
|
@@ -1333,46 +1486,46 @@ var getRecipeDescription = [
|
|
|
1333
1486
|
"Recipes yielding pieces rather than servings are rescaled the same way; check 'yield' to see which.",
|
|
1334
1487
|
"Always cite 'attribution' when showing a recipe to a user."
|
|
1335
1488
|
].join(" ");
|
|
1336
|
-
var
|
|
1337
|
-
id:
|
|
1338
|
-
url:
|
|
1489
|
+
var getRecipeInput = strictInput({
|
|
1490
|
+
id: z3.string().regex(/^\d+$/, "Recipe ids are digits only.").optional().describe("Marmiton recipe id, as returned by search_recipes. Preferred over 'url'."),
|
|
1491
|
+
url: z3.string().url().optional().describe(
|
|
1339
1492
|
"Full marmiton.org recipe URL. Only marmiton.org is accepted. Ignored when 'id' is given."
|
|
1340
1493
|
),
|
|
1341
|
-
servings:
|
|
1494
|
+
servings: z3.number().positive().max(500).optional().describe(
|
|
1342
1495
|
"Rescale the ingredients to this many servings. Omit to get the recipe exactly as published."
|
|
1343
1496
|
)
|
|
1344
|
-
};
|
|
1497
|
+
});
|
|
1345
1498
|
var getRecipeOutputShape = {
|
|
1346
|
-
id:
|
|
1347
|
-
title:
|
|
1348
|
-
url:
|
|
1349
|
-
yield:
|
|
1350
|
-
original_count:
|
|
1351
|
-
original_text:
|
|
1352
|
-
requested:
|
|
1353
|
-
unit:
|
|
1354
|
-
factor:
|
|
1499
|
+
id: z3.string(),
|
|
1500
|
+
title: z3.string(),
|
|
1501
|
+
url: z3.string(),
|
|
1502
|
+
yield: z3.object({
|
|
1503
|
+
original_count: z3.number().nullable(),
|
|
1504
|
+
original_text: z3.string(),
|
|
1505
|
+
requested: z3.number().nullable(),
|
|
1506
|
+
unit: z3.string().nullable().describe("What is being counted: 'personnes', 'pi\xE8ces'."),
|
|
1507
|
+
factor: z3.number().nullable().describe("Multiplier applied to the quantities.")
|
|
1355
1508
|
}),
|
|
1356
|
-
ingredients:
|
|
1357
|
-
steps:
|
|
1358
|
-
prep_minutes:
|
|
1359
|
-
cook_minutes:
|
|
1360
|
-
total_minutes:
|
|
1361
|
-
category:
|
|
1362
|
-
author:
|
|
1363
|
-
rating:
|
|
1364
|
-
nutrition:
|
|
1365
|
-
calories:
|
|
1366
|
-
protein:
|
|
1367
|
-
fat:
|
|
1368
|
-
carbohydrate:
|
|
1369
|
-
fiber:
|
|
1370
|
-
sodium:
|
|
1371
|
-
serving_size:
|
|
1509
|
+
ingredients: z3.array(scaledIngredientSchema),
|
|
1510
|
+
steps: z3.array(z3.string()),
|
|
1511
|
+
prep_minutes: z3.number().int().nullable(),
|
|
1512
|
+
cook_minutes: z3.number().int().nullable(),
|
|
1513
|
+
total_minutes: z3.number().int().nullable(),
|
|
1514
|
+
category: z3.string().nullable(),
|
|
1515
|
+
author: z3.string().nullable(),
|
|
1516
|
+
rating: z3.object({ value: z3.number(), count: z3.number().nullable(), best: z3.number().nullable() }).nullable(),
|
|
1517
|
+
nutrition: z3.object({
|
|
1518
|
+
calories: z3.string().nullable(),
|
|
1519
|
+
protein: z3.string().nullable(),
|
|
1520
|
+
fat: z3.string().nullable(),
|
|
1521
|
+
carbohydrate: z3.string().nullable(),
|
|
1522
|
+
fiber: z3.string().nullable(),
|
|
1523
|
+
sodium: z3.string().nullable(),
|
|
1524
|
+
serving_size: z3.string().nullable()
|
|
1372
1525
|
}).nullable().describe("Nutrition is given for the recipe as published, not rescaled."),
|
|
1373
|
-
attribution:
|
|
1374
|
-
source:
|
|
1375
|
-
notes:
|
|
1526
|
+
attribution: z3.string(),
|
|
1527
|
+
source: z3.literal("marmiton.org"),
|
|
1528
|
+
notes: z3.array(z3.string())
|
|
1376
1529
|
};
|
|
1377
1530
|
async function runGetRecipe(client, args) {
|
|
1378
1531
|
try {
|
|
@@ -1491,32 +1644,34 @@ ${stepLines}` : ""
|
|
|
1491
1644
|
}
|
|
1492
1645
|
|
|
1493
1646
|
// src/tools/scaleIngredients.ts
|
|
1494
|
-
import { z as
|
|
1647
|
+
import { z as z4 } from "zod";
|
|
1495
1648
|
var scaleIngredientsDescription = [
|
|
1496
1649
|
"Rescale a list of ingredient lines to a different number of servings, without contacting any website.",
|
|
1497
1650
|
"Give either 'factor' directly, or 'from_servings' and 'to_servings' and the factor is computed.",
|
|
1498
1651
|
"Works on any French ingredient list, whatever its source, so it also serves a recipe the user pasted in.",
|
|
1499
|
-
"Quantities in grams or millilitres are multiplied and rounded to readable values; countable
|
|
1500
|
-
"
|
|
1652
|
+
"Quantities in grams or millilitres are multiplied and rounded to readable values; a countable thing lands on",
|
|
1653
|
+
"a half when half of one can be poured, weighed or cut, as a bo\xEEte, a sachet, a feuille de g\xE9latine or a",
|
|
1654
|
+
"cuill\xE8re can, and on a whole one when it cannot, as an oeuf, a jaune or a blanc; approximate measures such",
|
|
1655
|
+
"as a pinch or a handful have",
|
|
1501
1656
|
"their count multiplied in whole units and stay in their own vocabulary. A line writing an article where a",
|
|
1502
1657
|
"digit would go, as in 'un bouchon de rhum' or 'une pinc\xE9e de sel', is read as one of that measure. Lines",
|
|
1503
1658
|
"carrying no quantity are returned untouched and flagged. Prefer this over doing the arithmetic yourself."
|
|
1504
1659
|
].join(" ");
|
|
1505
|
-
var
|
|
1506
|
-
ingredients:
|
|
1507
|
-
factor:
|
|
1508
|
-
from_servings:
|
|
1509
|
-
to_servings:
|
|
1510
|
-
};
|
|
1660
|
+
var scaleIngredientsInput = strictInput({
|
|
1661
|
+
ingredients: z4.array(z4.string().max(300)).min(1).max(100).describe("Ingredient lines, for example ['200 g de farine', '3 oeufs', 'sel']."),
|
|
1662
|
+
factor: z4.number().positive().max(100).optional().describe("Multiplier to apply. Use this or the from/to pair."),
|
|
1663
|
+
from_servings: z4.number().positive().max(500).optional().describe("How many servings the list is written for."),
|
|
1664
|
+
to_servings: z4.number().positive().max(500).optional().describe("How many servings are wanted.")
|
|
1665
|
+
});
|
|
1511
1666
|
var scaleIngredientsOutputShape = {
|
|
1512
|
-
factor:
|
|
1513
|
-
ingredients:
|
|
1514
|
-
scaled_count:
|
|
1515
|
-
rounded_count:
|
|
1667
|
+
factor: z4.number(),
|
|
1668
|
+
ingredients: z4.array(scaledIngredientSchema),
|
|
1669
|
+
scaled_count: z4.number().int(),
|
|
1670
|
+
rounded_count: z4.number().int().describe(
|
|
1516
1671
|
"Lines whose value rounding moved away from the exact product, not lines that could have been rounded."
|
|
1517
1672
|
),
|
|
1518
|
-
unscaled_count:
|
|
1519
|
-
notes:
|
|
1673
|
+
unscaled_count: z4.number().int(),
|
|
1674
|
+
notes: z4.array(z4.string())
|
|
1520
1675
|
};
|
|
1521
1676
|
function formatFactor(factor) {
|
|
1522
1677
|
return String(Number(factor.toPrecision(3)));
|
|
@@ -1590,7 +1745,7 @@ ${lines}`, notes);
|
|
|
1590
1745
|
}
|
|
1591
1746
|
|
|
1592
1747
|
// src/tools/searchRecipes.ts
|
|
1593
|
-
import { z as
|
|
1748
|
+
import { z as z5 } from "zod";
|
|
1594
1749
|
var searchRecipesDescription = [
|
|
1595
1750
|
"Search Marmiton for recipes by free text: a dish, an ingredient, or both, for example 'tarte aux pommes'",
|
|
1596
1751
|
"or 'poulet curry coco'. Marmiton is a French site, so French terms work best.",
|
|
@@ -1598,28 +1753,28 @@ var searchRecipesDescription = [
|
|
|
1598
1753
|
"One call returns one page of results. Marmiton's robots.txt disallows paginating search results, so this",
|
|
1599
1754
|
"server does not, and there is no page parameter: narrow the query instead of asking for more pages."
|
|
1600
1755
|
].join(" ");
|
|
1601
|
-
var
|
|
1756
|
+
var searchRecipesInput = strictInput({
|
|
1602
1757
|
// Deliberately no min(1): an empty string would be rejected by the schema with
|
|
1603
1758
|
// a protocol-level validation error, while a whitespace-only one would reach
|
|
1604
1759
|
// the tool and come back as invalid_input. Letting both through to the same
|
|
1605
1760
|
// check gives callers one error code for one problem.
|
|
1606
|
-
query:
|
|
1607
|
-
limit:
|
|
1608
|
-
};
|
|
1761
|
+
query: z5.string().max(200).describe("What to search for, in French, for example 'tarte aux pommes'."),
|
|
1762
|
+
limit: z5.number().int().min(1).max(30).default(10).describe("Maximum recipes to return from this page of results.")
|
|
1763
|
+
});
|
|
1609
1764
|
var searchRecipesOutputShape = {
|
|
1610
|
-
query:
|
|
1611
|
-
results:
|
|
1612
|
-
|
|
1613
|
-
id:
|
|
1614
|
-
title:
|
|
1615
|
-
url:
|
|
1616
|
-
image_url:
|
|
1765
|
+
query: z5.string(),
|
|
1766
|
+
results: z5.array(
|
|
1767
|
+
z5.object({
|
|
1768
|
+
id: z5.string().describe("Marmiton recipe id. Pass this to get_recipe."),
|
|
1769
|
+
title: z5.string(),
|
|
1770
|
+
url: z5.string(),
|
|
1771
|
+
image_url: z5.string().nullable()
|
|
1617
1772
|
})
|
|
1618
1773
|
),
|
|
1619
|
-
result_count:
|
|
1620
|
-
total_available:
|
|
1621
|
-
source:
|
|
1622
|
-
notes:
|
|
1774
|
+
result_count: z5.number().int(),
|
|
1775
|
+
total_available: z5.number().int().describe("Recipes on this page before applying 'limit'."),
|
|
1776
|
+
source: z5.literal("marmiton.org"),
|
|
1777
|
+
notes: z5.array(z5.string())
|
|
1623
1778
|
};
|
|
1624
1779
|
async function runSearchRecipes(client, args) {
|
|
1625
1780
|
try {
|
|
@@ -1691,8 +1846,8 @@ function createServer(options = {}) {
|
|
|
1691
1846
|
{
|
|
1692
1847
|
title: "Search recipes",
|
|
1693
1848
|
description: searchRecipesDescription,
|
|
1694
|
-
inputSchema:
|
|
1695
|
-
outputSchema:
|
|
1849
|
+
inputSchema: searchRecipesInput,
|
|
1850
|
+
outputSchema: z6.object(searchRecipesOutputShape),
|
|
1696
1851
|
annotations: READ_ONLY
|
|
1697
1852
|
},
|
|
1698
1853
|
async (args) => runSearchRecipes(client, args)
|
|
@@ -1702,8 +1857,8 @@ function createServer(options = {}) {
|
|
|
1702
1857
|
{
|
|
1703
1858
|
title: "Get a recipe",
|
|
1704
1859
|
description: getRecipeDescription,
|
|
1705
|
-
inputSchema:
|
|
1706
|
-
outputSchema:
|
|
1860
|
+
inputSchema: getRecipeInput,
|
|
1861
|
+
outputSchema: z6.object(getRecipeOutputShape),
|
|
1707
1862
|
annotations: READ_ONLY
|
|
1708
1863
|
},
|
|
1709
1864
|
async (args) => runGetRecipe(client, args)
|
|
@@ -1713,8 +1868,8 @@ function createServer(options = {}) {
|
|
|
1713
1868
|
{
|
|
1714
1869
|
title: "Scale an ingredient list",
|
|
1715
1870
|
description: scaleIngredientsDescription,
|
|
1716
|
-
inputSchema:
|
|
1717
|
-
outputSchema:
|
|
1871
|
+
inputSchema: scaleIngredientsInput,
|
|
1872
|
+
outputSchema: z6.object(scaleIngredientsOutputShape),
|
|
1718
1873
|
annotations: OFFLINE
|
|
1719
1874
|
},
|
|
1720
1875
|
async (args) => runScaleIngredients(args)
|