comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
443 lines • 16.3 kB
JavaScript
import { randomInt } from "node:crypto";
/**
* Safe batch expression evaluator — zero dependencies, no `eval`/`new Function`.
*
* A tiny hand-rolled tokenizer + Pratt parser that evaluates arithmetic
* directly (no retained AST). Numbers only: no strings, arrays, objects, or
* property access. Statement-level assignment (`name = expr`) persists across
* lines. Seeded RNG (mulberry32) makes `rand`/`uniform`/`randint` reproducible.
*
* See design/calculate-tool.md for the full design.
*/
/** Hard caps to keep a single call bounded. */
export const MAX_EXPRESSIONS = 1000;
export const MAX_EXPR_CHARS = 10_000;
// ---------------------------------------------------------------------------
// Seeded RNG — mulberry32
// ---------------------------------------------------------------------------
/** mulberry32: a fast, decent 32-bit seeded PRNG. Returns floats in [0, 1). */
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Multi-char operators must be tried before their single-char prefixes.
const OPERATORS = ["**", "//", "<=", ">=", "==", "!=", "+", "-", "*", "/", "%", "<", ">", "="];
function tokenize(src) {
const tokens = [];
let i = 0;
const n = src.length;
while (i < n) {
const ch = src[i];
// whitespace
if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
i++;
continue;
}
// numbers: int / float / scientific
if (ch >= "0" && ch <= "9") {
const start = i;
while (i < n && src[i] >= "0" && src[i] <= "9")
i++;
if (src[i] === ".") {
i++;
while (i < n && src[i] >= "0" && src[i] <= "9")
i++;
}
if (src[i] === "e" || src[i] === "E") {
let j = i + 1;
if (src[j] === "+" || src[j] === "-")
j++;
if (j < n && src[j] >= "0" && src[j] <= "9") {
i = j;
while (i < n && src[i] >= "0" && src[i] <= "9")
i++;
}
}
tokens.push({ type: "number", value: src.slice(start, i), pos: start });
continue;
}
// leading-dot floats: `.5`
if (ch === "." && src[i + 1] >= "0" && src[i + 1] <= "9") {
const start = i;
i++;
while (i < n && src[i] >= "0" && src[i] <= "9")
i++;
if (src[i] === "e" || src[i] === "E") {
let j = i + 1;
if (src[j] === "+" || src[j] === "-")
j++;
if (j < n && src[j] >= "0" && src[j] <= "9") {
i = j;
while (i < n && src[i] >= "0" && src[i] <= "9")
i++;
}
}
tokens.push({ type: "number", value: src.slice(start, i), pos: start });
continue;
}
// identifiers: letter or underscore, then alphanumerics/underscore
if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_") {
const start = i;
while (i < n &&
((src[i] >= "a" && src[i] <= "z") ||
(src[i] >= "A" && src[i] <= "Z") ||
(src[i] >= "0" && src[i] <= "9") ||
src[i] === "_"))
i++;
tokens.push({ type: "ident", value: src.slice(start, i), pos: start });
continue;
}
if (ch === "(") {
tokens.push({ type: "lparen", value: "(", pos: i });
i++;
continue;
}
if (ch === ")") {
tokens.push({ type: "rparen", value: ")", pos: i });
i++;
continue;
}
if (ch === ",") {
tokens.push({ type: "comma", value: ",", pos: i });
i++;
continue;
}
// operators (multi-char first)
let matched = false;
for (const op of OPERATORS) {
if (src.startsWith(op, i)) {
tokens.push({ type: "op", value: op, pos: i });
i += op.length;
matched = true;
break;
}
}
if (matched)
continue;
throw new Error(`Unexpected character '${ch}' at position ${i}`);
}
tokens.push({ type: "eof", value: "", pos: n });
return tokens;
}
// ---------------------------------------------------------------------------
// Constants & function table
// ---------------------------------------------------------------------------
const CONSTANTS = {
pi: Math.PI,
e: Math.E,
tau: Math.PI * 2,
};
function radians(x) {
return (x * Math.PI) / 180;
}
function degrees(x) {
return (x * 180) / Math.PI;
}
function clamp(x, lo, hi) {
return Math.min(Math.max(x, lo), hi);
}
const FUNCTIONS = {
abs: { min: 1, max: 1, fn: (a) => Math.abs(a[0]) },
round: { min: 1, max: 1, fn: (a) => Math.round(a[0]) },
min: { min: 1, max: Infinity, fn: (a) => Math.min(...a) },
max: { min: 1, max: Infinity, fn: (a) => Math.max(...a) },
pow: { min: 2, max: 2, fn: (a) => Math.pow(a[0], a[1]) },
sqrt: { min: 1, max: 1, fn: (a) => Math.sqrt(a[0]) },
floor: { min: 1, max: 1, fn: (a) => Math.floor(a[0]) },
ceil: { min: 1, max: 1, fn: (a) => Math.ceil(a[0]) },
sin: { min: 1, max: 1, fn: (a) => Math.sin(a[0]) },
cos: { min: 1, max: 1, fn: (a) => Math.cos(a[0]) },
tan: { min: 1, max: 1, fn: (a) => Math.tan(a[0]) },
asin: { min: 1, max: 1, fn: (a) => Math.asin(a[0]) },
acos: { min: 1, max: 1, fn: (a) => Math.acos(a[0]) },
atan: { min: 1, max: 1, fn: (a) => Math.atan(a[0]) },
atan2: { min: 2, max: 2, fn: (a) => Math.atan2(a[0], a[1]) },
sinh: { min: 1, max: 1, fn: (a) => Math.sinh(a[0]) },
cosh: { min: 1, max: 1, fn: (a) => Math.cosh(a[0]) },
tanh: { min: 1, max: 1, fn: (a) => Math.tanh(a[0]) },
exp: { min: 1, max: 1, fn: (a) => Math.exp(a[0]) },
log: { min: 1, max: 2, fn: (a) => (a.length === 2 ? Math.log(a[0]) / Math.log(a[1]) : Math.log(a[0])) },
log10: { min: 1, max: 1, fn: (a) => Math.log10(a[0]) },
log2: { min: 1, max: 1, fn: (a) => Math.log2(a[0]) },
hypot: { min: 1, max: Infinity, fn: (a) => Math.hypot(...a) },
radians: { min: 1, max: 1, fn: (a) => radians(a[0]) },
degrees: { min: 1, max: 1, fn: (a) => degrees(a[0]) },
sign: { min: 1, max: 1, fn: (a) => Math.sign(a[0]) },
trunc: { min: 1, max: 1, fn: (a) => Math.trunc(a[0]) },
clamp: { min: 3, max: 3, fn: (a) => clamp(a[0], a[1], a[2]) },
// RNG-backed
rand: { min: 0, max: 0, fn: (_a, rng) => rng() },
random: { min: 0, max: 0, fn: (_a, rng) => rng() },
uniform: { min: 2, max: 2, fn: (a, rng) => a[0] + rng() * (a[1] - a[0]) },
// inclusive both ends, matching Python's random.randint
randint: {
min: 2,
max: 2,
fn: (a, rng) => {
const lo = Math.ceil(a[0]);
const hi = Math.floor(a[1]);
if (hi < lo)
throw new Error(`randint: empty range [${a[0]}, ${a[1]}]`);
return lo + Math.floor(rng() * (hi - lo + 1));
},
},
};
// ---------------------------------------------------------------------------
// Pratt parser + direct evaluator (single expression)
// ---------------------------------------------------------------------------
// Binary operator binding powers. Higher binds tighter. `**` is right-assoc.
const BINARY_BP = {
"==": 1,
"!=": 1,
"<": 1,
"<=": 1,
">": 1,
">=": 1,
"+": 2,
"-": 2,
"*": 3,
"/": 3,
"//": 3,
"%": 3,
"**": 5, // above unary (4) so `-2**2` = -(2**2)
};
class Evaluator {
env;
rng;
tokens;
idx = 0;
constructor(tokens, env, rng) {
this.env = env;
this.rng = rng;
this.tokens = tokens;
}
peek() {
return this.tokens[this.idx];
}
next() {
return this.tokens[this.idx++];
}
/** Parse+evaluate a full statement (top level). Handles `name = expr`. */
evalStatement() {
// Assignment: `ident = expr` (only as a full statement, no chaining).
if (this.peek().type === "ident" &&
this.tokens[this.idx + 1]?.type === "op" &&
this.tokens[this.idx + 1].value === "=") {
const name = this.next().value; // ident
this.next(); // '='
if (Object.hasOwn(CONSTANTS, name)) {
throw new Error(`Cannot assign to constant '${name}'`);
}
if (Object.hasOwn(FUNCTIONS, name)) {
throw new Error(`Cannot assign to function name '${name}'`);
}
const value = this.parseExpr(0);
this.expectEof();
this.env.set(name, value);
return value;
}
const value = this.parseExpr(0);
this.expectEof();
return value;
}
expectEof() {
if (this.peek().type !== "eof") {
const t = this.peek();
throw new Error(`Unexpected token '${t.value || t.type}' at position ${t.pos}`);
}
}
/** Pratt loop: parse a prefix, then fold in binary operators >= minBp. */
parseExpr(minBp) {
let left = this.parsePrefix();
for (;;) {
const t = this.peek();
if (t.type !== "op")
break;
const op = t.value;
if (op === "=")
break; // assignment is statement-only
const bp = BINARY_BP[op];
if (bp === undefined || bp < minBp)
break;
this.next(); // consume operator
const rightAssoc = op === "**";
const right = this.parseExpr(rightAssoc ? bp : bp + 1);
left = this.applyBinary(op, left, right);
}
return left;
}
parsePrefix() {
const t = this.peek();
// unary minus / plus (binding power 4 — below **, above binary +/-)
if (t.type === "op" && (t.value === "-" || t.value === "+")) {
this.next();
const operand = this.parseExpr(4);
return t.value === "-" ? -operand : operand;
}
if (t.type === "number") {
this.next();
return Number(t.value);
}
if (t.type === "lparen") {
this.next();
const value = this.parseExpr(0);
const close = this.next();
if (close.type !== "rparen") {
throw new Error(`Expected ')' at position ${close.pos}`);
}
return value;
}
if (t.type === "ident") {
this.next();
const name = t.value;
// function call?
if (this.peek().type === "lparen") {
const fnDef = Object.hasOwn(FUNCTIONS, name) ? FUNCTIONS[name] : undefined;
if (!fnDef)
throw new Error(`Unknown function '${name}'`);
const args = this.parseArgs();
if (args.length < fnDef.min || args.length > fnDef.max) {
const arity = fnDef.min === fnDef.max
? `${fnDef.min}`
: fnDef.max === Infinity
? `${fnDef.min}+`
: `${fnDef.min}-${fnDef.max}`;
throw new Error(`${name}() expects ${arity} argument(s), got ${args.length}`);
}
return fnDef.fn(args, this.rng);
}
// constant?
if (Object.hasOwn(CONSTANTS, name))
return CONSTANTS[name];
// variable?
if (this.env.has(name))
return this.env.get(name);
// Bare function name used as a value, or truly unknown.
if (Object.hasOwn(FUNCTIONS, name)) {
throw new Error(`'${name}' is a function; call it as ${name}(...)`);
}
throw new Error(`Unknown identifier '${name}'`);
}
throw new Error(`Unexpected token '${t.value || t.type}' at position ${t.pos}`);
}
parseArgs() {
this.next(); // consume '('
const args = [];
if (this.peek().type === "rparen") {
this.next();
return args;
}
for (;;) {
args.push(this.parseExpr(0));
const t = this.peek();
if (t.type === "comma") {
this.next();
continue;
}
if (t.type === "rparen") {
this.next();
break;
}
throw new Error(`Expected ',' or ')' at position ${t.pos}`);
}
return args;
}
applyBinary(op, l, r) {
switch (op) {
case "+":
return l + r;
case "-":
return l - r;
case "*":
return l * r;
case "/":
return l / r;
case "//":
return Math.floor(l / r);
case "%": {
// Python-style modulo (result takes sign of divisor).
return ((l % r) + r) % r;
}
case "**":
return Math.pow(l, r);
case "<":
return l < r ? 1 : 0;
case "<=":
return l <= r ? 1 : 0;
case ">":
return l > r ? 1 : 0;
case ">=":
return l >= r ? 1 : 0;
case "==":
return l === r ? 1 : 0;
case "!=":
return l !== r ? 1 : 0;
default:
throw new Error(`Unknown operator '${op}'`);
}
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Evaluate a batch of expressions with persistent assignments.
*
* @param spec One expression per array item. `name = expr` assigns and
* persists into later lines.
* @param variables Initial variable environment (not mutated).
* @param seed Seed for rand()/uniform()/randint(). When omitted, a seed
* is drawn via crypto and echoed in the result.
*/
export function evaluateBatch(spec, variables, seed) {
if (spec.length > MAX_EXPRESSIONS) {
throw new Error(`Too many expressions: ${spec.length} (max ${MAX_EXPRESSIONS})`);
}
// Resolve the seed: explicit (coerced to uint32) or drawn from crypto.
const resolvedSeed = seed === undefined ? randomInt(0, 0x1_0000_0000) : seed >>> 0;
const rng = mulberry32(resolvedSeed);
// Environment seeded from input variables; the input object is never mutated.
const env = new Map();
if (variables) {
for (const [k, v] of Object.entries(variables))
env.set(k, v);
}
const results = [];
const errors = [];
for (let i = 0; i < spec.length; i++) {
const expr = spec[i];
try {
if (expr.length > MAX_EXPR_CHARS) {
throw new Error(`Expression too long: ${expr.length} chars (max ${MAX_EXPR_CHARS})`);
}
const tokens = tokenize(expr);
// Bare comment/blank guard: a statement with no real tokens.
if (tokens.length === 1 && tokens[0].type === "eof") {
throw new Error("Empty expression");
}
const value = new Evaluator(tokens, env, rng).evalStatement();
results.push(value);
}
catch (err) {
results.push(null);
errors.push({
line: i + 1,
expr,
message: err instanceof Error ? err.message : String(err),
});
}
}
return {
results,
variables: Object.fromEntries(env),
errors,
seed: resolvedSeed,
};
}
//# sourceMappingURL=calc-evaluator.js.map