UNPKG

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),

618 lines • 33.6 kB
// Pure query engine over an API-format graph — the flat {id: {class_type, // inputs, _meta}} shape every mutation tool consumes (issue #169). // // Fills the "missing middle" between get_workflow (action:"analyze")'s fixed summary and // get_workflow's full JSON dump: on big graphs (100+ nodes) an agent needs // "the KSamplers with cfg>7", "what feeds node 42", "count nodes by type" — // not a context-flooding dump and not an unfilterable outline. Output is // TOKEN-BOUNDED with an explicit truncation marker, so a small local model // can never lose its conversation to a graph read (the trigger of the // context-swamp saga). The panel's live-graph twin (graph_query executor in // comfyui-mcp-panel) mirrors these exact semantics — keep them in lockstep. /** A widget predicate: "name op value". Ops: = != >= <= > < ~ (contains). */ const PREDICATE_RE = /^\s*([A-Za-z0-9_.]+)\s*(>=|<=|!=|=|>|<|~)\s*(.*?)\s*$/; // #809: the REAL clamps, exported so the remedy strings below and the zod schema on // get_workflow's query action (src/tools/workflow-library.ts) are derived from ONE number. A hint // that names a ceiling the runtime does not actually enforce is the same class of // defect as a hint that names the wrong parameter — it sends the caller at a value // that gets silently clamped, which reads to the caller as "the tool can't do this". export const LIMIT_CEILING = 200; export const MAX_CHARS_CEILING = 60000; export const MAX_CHARS_FLOOR = 500; /** Per-widget-value cap in the `detail` projection — a FIXED cap, NOT raised by max_chars. */ export const WIDGET_VALUE_CAP = 2048; /** Widget values are clipped to this in the `compact` projection (fixed). */ export const COMPACT_VALUE_CLIP = 60; /** Ref-inputs / downstream-consumer list caps in the `detail` projection (fixed). */ export const REF_CAP = 64; export const DOWN_CAP = 64; /** * #809 (codex gate): "raise `X` up to N" is ITSELF a dead retry when the caller is * already at N, and "raise `X`" with no ceiling leaves them guessing how far. Both waste * the round trip this issue exists to prevent. One helper so every marker — inline or * tail — states the same true thing. */ export function raiseOrCeiling(param, inForce, ceiling) { return inForce >= ceiling ? `\`${param}\` is already at its ceiling of ${ceiling}` : `raise \`${param}\` (up to ${ceiling})`; } /** Is this input value a [nodeId, slot] link reference? */ function isRef(v) { return Array.isArray(v) && v.length === 2 && (typeof v[0] === "string" || typeof v[0] === "number") && typeof v[1] === "number"; } function clip(v, n = 60) { const s = typeof v === "string" ? v : JSON.stringify(v); const one = (s ?? "").replace(/\s+/g, " "); return one.length > n ? `${one.slice(0, n - 1)}…` : one; } /** clip(), but reporting whether it actually cut — so the COMPACT projection can * raise ONE footer note naming the lever (`fields`:"detail") instead of leaving a * bare `…` per value (#809). */ function clipFlag(v, n = COMPACT_VALUE_CLIP) { const s = typeof v === "string" ? v : JSON.stringify(v); const one = (s ?? "").replace(/\s+/g, " "); return one.length > n ? { text: `${one.slice(0, n - 1)}…`, clipped: true } : { text: one, clipped: false }; } // #609: per-widget-value cap for the `detail` projection. One oversized value // (a ResolutionMaster presets JSON, LTXDirector.timeline_data, a VHS videopreview) // must not consume the whole max_chars budget and starve the rest of the query. // WIDGET_VALUE_CAP is exported above (#809) so the remedy can state it honestly. /** Bound one widget value by its ESCAPED (JSON-serialized) size (#609), so the bound * holds regardless of content — control chars and lone surrogates escape to 6 chars * each, not 2. A non-string is measured by its REAL serialized form (the single * stringify below) — re-serializing that JSON text would double-count every escape * and type-change a fitting object/array into a truncated string (review nit). * Small values pass through; else the longest head prefix whose encoding fits `cap`, * with an honest marker naming how many raw chars were dropped. */ function capWidgetValue(value, cap = WIDGET_VALUE_CAP, /** The `max_chars` in force, so a caller already at the ceiling isn't sent back. */ maxChars = Number.POSITIVE_INFINITY) { if (value == null) return value; const isString = typeof value === "string"; const s = isString ? value : (() => { try { return JSON.stringify(value); } catch { return String(value); } })(); if (typeof s !== "string") return value; // A string is emitted escaped (JSON.stringify(s)); a non-string's emission IS s. const serializedSize = isString ? JSON.stringify(s).length : s.length; if (serializedSize <= cap) return value; // #809: name the lever that ACTUALLY applies. capWidgets() tightens the per-value // cap to the char budget only when the budget is the smaller of the two, so // `cap < WIDGET_VALUE_CAP` ⟺ max_chars is what cut this value and raising it helps. // At the FIXED 2048 cap raising max_chars does nothing, and saying so stops the // caller burning a retry on a parameter that cannot move this. const remedy = cap < WIDGET_VALUE_CAP ? `over the \`max_chars\` budget; ${raiseOrCeiling("max_chars", maxChars, MAX_CHARS_CEILING)}` : `at the ${WIDGET_VALUE_CAP}-char per-widget cap, which \`max_chars\` does not raise`; const marker = (dropped) => `…(+${dropped} chars cut ${remedy})`; // Reserve EXACTLY the marker's own size (its digit count is bounded by s.length), // not a fixed 40 — a longer, more useful marker must not be able to breach `cap`. const target = Math.max(2, cap - marker(s.length).length); let lo = 0; let hi = s.length; let best = 0; while (lo <= hi) { const mid = (lo + hi) >> 1; if (JSON.stringify(s.slice(0, mid)).length <= target) { best = mid; lo = mid + 1; } else hi = mid - 1; } return `${s.slice(0, best)}${marker(s.length - best)}`; } /** Clip a KEY/name to a small bound (#609) so a pathological long key can't blow the line. */ function capKey(key, cap = 128) { const k = String(key); return k.length <= cap ? k : `${k.slice(0, cap)}…`; } /** Serialized (JSON-escaped) size of one `"key":value,` widget entry — the ACTUAL * line contribution, so escape-heavy strings don't slip the budget. */ function entrySize(key, value) { let vLen; try { vLen = JSON.stringify(value)?.length ?? 0; } catch { vLen = String(value).length; } return JSON.stringify(key).length + vLen + 2; } /** Cap every value in a widgets map, AND bound the TOTAL serialized size by dropping * overflow widgets (keeping valid JSON) with an elision marker (#609) — so even a * many-widget node can't blow the char budget. When a budget is set, the per-value * cap is tightened to it so even the single retained widget respects it. At least one * widget always survives. */ function capWidgets(widgets, totalCap = Number.POSITIVE_INFINITY) { const out = {}; // capWidgetValue bounds the ESCAPED size exactly, so the per-value cap can be the // budget itself (minus a small reserve for the key + JSON framing). const perValueCap = Number.isFinite(totalCap) ? Math.min(WIDGET_VALUE_CAP, Math.max(1, totalCap - 256)) : WIDGET_VALUE_CAP; const entries = Object.entries(widgets); let used = 0; let omitted = 0; for (let i = 0; i < entries.length; i++) { const k = capKey(entries[i][0]); const capped = capWidgetValue(entries[i][1], perValueCap, totalCap); const size = entrySize(k, capped); if (Number.isFinite(totalCap) && Object.keys(out).length > 0 && used + size > totalCap) { omitted = entries.length - i; break; } out[k] = capped; used += size; } // #809: `max_chars` backticked so the schema-driven remedy test can see it, and the // remaining count stated — the caller needs "how much is left", not just "some". if (omitted > 0) out["…"] = `${omitted} more widget(s) cut by the \`max_chars\` budget; ` + raiseOrCeiling("max_chars", totalCap, MAX_CHARS_CEILING); return out; } /** Hard-clip an assembled COMPACT (plain-string) line to `maxChars` (#609) — the * protected first match bypasses the running budget, so a thousands-of-widgets node * would else emit an unbounded first line. */ function clipLine(line, maxChars) { if (!Number.isFinite(maxChars) || line.length <= maxChars) return line; // #809: a bare "…" told the caller nothing. Say how much was cut and name the lever // (raising max_chars genuinely lifts THIS cut). The reserve is the marker's own // worst-case size, so the clipped line still respects maxChars. const marker = (dropped) => `…(+${dropped} chars over \`max_chars\`; ${raiseOrCeiling("max_chars", maxChars, MAX_CHARS_CEILING)})`; const keep = Math.max(0, maxChars - marker(line.length).length); return line.slice(0, keep) + marker(line.length - keep); } /** Final guard for a DETAIL (JSON) line (#609): if a node's fully-capped detail STILL * exceeds `maxChars` — a high-fan-in node whose upstream/downstream the per-field * caps don't fully bound against a shared budget — replace it with a bounded * VALID-JSON stub. The row is never dropped (shown ≥ 1) but every rendered detail * line is ≤ maxChars. */ function fitDetailLine(line, stub, maxChars) { if (!Number.isFinite(maxChars) || line.length <= maxChars) return line; // Clip the stub's OWN fields too — a graph may carry an arbitrarily long node id or // type, which would otherwise blow even the stub past the budget. const clipF = (v) => (typeof v === "string" && v.length > 60 ? `${v.slice(0, 60)}…` : v); const safe = { id: clipF(stub.id), type: clipF(stub.type) }; if (stub.title != null) safe.title = clipF(stub.title); const s = JSON.stringify({ ...safe, detail_omitted: `full detail is ${line.length} chars > \`max_chars\` ${maxChars}; ${raiseOrCeiling("max_chars", maxChars, MAX_CHARS_CEILING)} to read this node`, }); if (s.length <= maxChars) return s; return JSON.stringify({ id: typeof safe.id === "string" ? safe.id.slice(0, 40) : safe.id, detail_omitted: "raise `max_chars`" }); } function matchPredicate(value, op, rhs) { const lhsNum = typeof value === "number" ? value : Number(value); const rhsNum = Number(rhs); const numeric = !Number.isNaN(lhsNum) && !Number.isNaN(rhsNum) && String(value).trim() !== ""; if (numeric) { switch (op) { case "=": return lhsNum === rhsNum; case "!=": return lhsNum !== rhsNum; case ">": return lhsNum > rhsNum; case ">=": return lhsNum >= rhsNum; case "<": return lhsNum < rhsNum; case "<=": return lhsNum <= rhsNum; // "~" falls through to string contains below } } const l = String(value ?? "").toLowerCase(); const r = rhs.toLowerCase(); switch (op) { case "=": return l === r; case "!=": return l !== r; case "~": return l.includes(r); // Ordered ops on non-numeric values: lexicographic (rarely useful, never throws). case ">": return l > r; case ">=": return l >= r; case "<": return l < r; case "<=": return l <= r; default: return false; } } /** BFS over an adjacency map, depth-capped (seed = depth 0, included). */ function closure(adj, seed, depth) { const seen = new Set([seed]); let frontier = [seed]; for (let d = 0; d < depth && frontier.length; d++) { const next = []; for (const id of frontier) { for (const n of adj.get(id) ?? []) { if (!seen.has(n)) { seen.add(n); next.push(n); } } } frontier = next; } return seen; } /** * Query an API-format graph. Pipeline: traversal scope → filters → projection * (or aggregate), then char-bound the text. Never throws on malformed nodes — * a node without class_type/inputs simply matches less. */ export function queryApiGraph(graph, opts = {}) { const nodeIds = Object.keys(graph); const total = nodeIds.length; const limit = Math.min(Math.max(opts.limit ?? 40, 1), LIMIT_CEILING); const maxChars = Math.min(Math.max(opts.max_chars ?? 12000, MAX_CHARS_FLOOR), MAX_CHARS_CEILING); // Adjacency (upstream = the nodes my ref-inputs point at; downstream = inverse). const up = new Map(); const down = new Map(); for (const id of nodeIds) { for (const v of Object.values(graph[id]?.inputs ?? {})) { if (isRef(v)) { const src = String(v[0]); if (!up.has(id)) up.set(id, new Set()); up.get(id).add(src); if (!down.has(src)) down.set(src, new Set()); down.get(src).add(id); } } } // 1) Traversal scope. let scope = null; const depth = opts.depth != null && opts.depth >= 0 ? opts.depth : Number.POSITIVE_INFINITY; const seedErr = (which, id) => ({ total, candidates: 0, matched: 0, shown: 0, truncated: false, truncated_by: null, // #609: clip the caller-supplied id so an oversized seed can't flood the diagnostic. // #809: SAY when it was clipped — echoing back a shortened id with a bare "…" reads // as "that is the id I looked for", so the caller cannot tell a typo from a clip. text: `${which} node ${clip(id, 120)}${id.length > 120 ? ` (id shown clipped to 120 of ${id.length} chars)` : ""}` + ` not found in the graph (${total} nodes).`, }); if (opts.upstream_of != null) { const seed = String(opts.upstream_of); if (!graph[seed]) return seedErr("upstream_of", seed); scope = closure(up, seed, depth); } if (opts.downstream_of != null) { const seed = String(opts.downstream_of); if (!graph[seed]) return seedErr("downstream_of", seed); const d = closure(down, seed, depth); scope = scope ? new Set([...scope].filter((x) => d.has(x))) : d; } const candidates = scope ? [...scope] : nodeIds; // 2) Filters. const wantIds = opts.ids?.map(String); const types = opts.types?.map((t) => t.toLowerCase()).filter(Boolean); const title = opts.title?.toLowerCase(); const predicates = (opts.where ?? []).map((w) => { const m = PREDICATE_RE.exec(w); // Reject a value that starts with an operator char — it means the op was // mistyped ("cfg >> 7", "steps => 20") and would otherwise silently match // nothing as a string compare. if (!m || /^[=<>~]/.test(m[3])) { // #609: clip the caller-supplied predicate so an oversized value can't flood the error. throw new Error(`Bad predicate "${clip(w, 120)}" — expected "name op value" with op one of = != >= <= > < ~`); } return { name: m[1], op: m[2], rhs: m[3] }; }); const widgetsOf = (n) => { const out = {}; for (const [k, v] of Object.entries(n.inputs ?? {})) if (!isRef(v)) out[k] = v; return out; }; const matched = candidates.filter((id) => { const n = graph[id] ?? {}; if (wantIds && !wantIds.includes(String(id))) return false; const t = (n.class_type ?? "").toLowerCase(); if (types?.length && !types.some((x) => t.includes(x))) return false; if (title && !(n._meta?.title ?? "").toLowerCase().includes(title)) return false; if (predicates.length) { const w = widgetsOf(n); for (const p of predicates) { if (!(p.name in w) || !matchPredicate(w[p.name], p.op, p.rhs)) return false; } } return true; }); // Stable numeric-ish ordering so results read like the graph. matched.sort((a, b) => (Number(a) || 0) - (Number(b) || 0) || a.localeCompare(b)); // 3) Aggregate? if (opts.group_by === "type") { const hist = new Map(); for (const id of matched) { const t = graph[id]?.class_type ?? "?"; hist.set(t, (hist.get(t) ?? 0) + 1); } // #609: bound the aggregate too — clip each type and char-bound the list so a graph // with thousands of distinct/long class_types can't flood the read. const allLines = [...hist.entries()].sort((a, b) => b[1] - a[1]).map(([t, c]) => `${c}× ${clip(t, 200)}`); const head = `${matched.length} node(s) across ${hist.size} type(s):`; const kept = []; let used = head.length; let aggTruncated = false; for (const l of allLines) { if (used + l.length + 1 > maxChars) { aggTruncated = true; break; } kept.push(l); used += l.length + 1; } // #809: the aggregate can ONLY be cut by the char budget (limit is ignored here), // so name that one lever and its real ceiling. const aggAssemble = () => { const tail = aggTruncated ? `\n…(${allLines.length - kept.length} more type(s) cut by \`max_chars\`=${maxChars}; ${maxChars >= MAX_CHARS_CEILING ? `\`max_chars\` is already at its ceiling of ${MAX_CHARS_CEILING} — narrow the matched set with \`types\`/\`where\`/\`upstream_of\` instead` : `raise \`max_chars\` up to ${MAX_CHARS_CEILING}`})` : ""; return `${head}\n${kept.join("\n")}${tail}`; }; // The tail is part of the result here too (codex gate) — the loop above filled the // budget with rows and then appended it, breaching the bound on exactly the path the // row projection had already been fixed for. Fit afterwards, same as there. let aggText = aggAssemble(); while (aggText.length > maxChars && kept.length > 0) { kept.pop(); aggTruncated = true; aggText = aggAssemble(); } return { total, candidates: candidates.length, matched: matched.length, shown: matched.length, truncated: aggTruncated, truncated_by: aggTruncated ? "max_chars" : null, text: aggText, }; } // 4) Projection, char-bounded. const fields = opts.fields ?? "compact"; // #1634: a compact row's 60-char value clip is a SURVEY cap — it exists so a 200-node // listing of nodes you have not identified yet stays small. When the caller passed // explicit `ids` they have ALREADY identified the nodes, so the read is a PINPOINT one // and the survey cap is simply starving the value they asked for. Measured on a 4-node // graph: `{ids:["2"]}` returned the prompt cut at 60 chars in a 301-char reply against // a 12000-char budget — the clip was not protecting anything, and the cut-off value was // quoted back to the user as the node's prompt. // // So a pinpoint read gets the SAME per-value cap the `detail` projection uses. Note // what this deliberately does NOT do: it does not change the compact SHAPE (still one // line per node), and it does not touch the survey path or the outline ladder. // // Only a SINGLE id is a pinpoint read. Treating any `ids` list as one cost rows the // caller explicitly asked for and main returned in full — 20 ordinary 600-char prompts // at the default budget went from 20/20 to 18/20 (gate). One id also means at most one // row, so there is no budget to divide and no row-count to regress. const pinpoint = wantIds?.length === 1; // Raise the cap only when the generous row DEMONSTRABLY fits. Arithmetic reserves kept // leaking — a floor of 60 per widget still grows without bound across many widgets, so // a 24-widget node breached `max_chars` on default parameters while reporting // truncated:false (gate). A fit test cannot leak: if the generous rendering does not fit, // we use main's rendering instead. // // Precisely: this does not make the `max_chars` bound any softer than main's. The bound // is already soft — `clipLine` bounds the LINE, while the header and clip note ride // outside it, so main overshoots by ~173 chars on hostile shapes too (1893 of 4000 in // the gate's fuzz). This branch overshoots on 80 of those 4000 by the same magnitude. // It does not FIX that pre-existing softness, and it must not be read as claiming to. const compactValueCap = (() => { if (!pinpoint) return COMPACT_VALUE_CLIP; const n = graph[wantIds[0]] ?? {}; let total = 0; for (const [k, v] of Object.entries(widgetsOf(n))) total += k.length + 2 + clipFlag(v, WIDGET_VALUE_CAP).text.length; // The reserve covers what shares the budget with the row: header, the row's own // prefix and ref lists, the truncation tail and the clip note. return total <= maxChars - 1024 ? WIDGET_VALUE_CAP : COMPACT_VALUE_CLIP; })(); const header = `${matched.length} match(es) of ${candidates.length} in scope (graph: ${total} nodes)` + (scope ? ` · traversal${Number.isFinite(depth) ? ` depth≤${depth}` : ""}` : ""); // #609: protect ONLY the FIRST match from the char budget so asking for one node // by id never returns shown:0 (a huge sibling or one oversized widget blob can no // longer starve the thing you asked for). We deliberately do NOT exempt every // requested id — that would disable the max_chars token bound for a large `ids` // list; at most one line (the first, itself per-value capped) can overflow. // NOTE (#607): link-driven-widget staleness cannot occur in this API-format engine // — ref inputs are excluded from widgetsOf() by construction (isRef), so a // link-driven input is never reported as a stale widget value. #607 is panel-only. const lines = []; let shown = 0; let truncated = false; // #809: WHICH cap fired. `limit` and `max_chars` are different levers with opposite // remedies — the pre-#809 tail named `limit` for both, so a caller cut by the char // budget was told to raise the one parameter that makes the overflow worse. let truncatedBy = null; /** #809: compact rows clip widget values at a FIXED 60 chars, which no parameter * raises — so the footer points at `fields`:"detail" (the projection that carries * fuller values) rather than at a lever that would do nothing. * * Counted PER ROW and summed over the rows actually RETURNED (codex gate). A running * total would include rows the budget rejected and rows the post-fit loop popped, so * the footer would claim clips the reader cannot see — a false claim inside the very * note that has to be trustworthy. */ const lineClips = []; let rowClips = 0; let chars = header.length; for (const id of matched) { if (shown >= limit) { truncated = true; truncatedBy = "limit"; break; } const n = graph[id] ?? {}; rowClips = 0; let line; if (fields === "ids") { line = clipLine(String(id), maxChars); // #609: bound even a pathological long id } else if (fields === "detail") { // #609: bound EVERY field so the protected first line stays O(max_chars): clip // the title/type, clip ref-input names + source ids and cap their count, and cap // the downstream consumer list (a fan-out hub can have hundreds) — each with a // "+N more" / "…" tail. widgets is total-capped by capWidgets. const upRefs = {}; const refEntries = Object.entries(n.inputs ?? {}).filter(([, v]) => isRef(v)); for (const [k, v] of refEntries.slice(0, REF_CAP)) { upRefs[capKey(k)] = clip(`${v[0]}.${v[1]}`, 128); } // #809: these two are FIXED caps that no parameter raises — so the marker names // the traversal that DOES return the full set instead of a lever that cannot. // The suggested follow-up carries its OWN `limit` (codex gate): a bare // `upstream_of`+`depth`:1 would re-truncate at the default limit of 40 and land the // caller in a second silent cut, which is the failure this issue exists to stop. // `fields`:"ids" keeps that follow-up cheap. The seed counts as one row, hence +1. // The traversal returns the seed PLUS every direct neighbour, so it needs // count+1 rows. Past LIMIT_CEILING that is unreachable in one call, and claiming // "list all" there would be a second false promise (codex gate) — so the verb // changes with the fact. const followUpText = (param, count) => { const need = count + 1; // the traversal returns the seed PLUS its neighbours const call = `\`${param}\`:${id}, \`depth\`:1, \`limit\`:${Math.min(LIMIT_CEILING, need)}, \`fields\`:"ids"`; // Above the ceiling a single call CANNOT return them all, and the query action has // no cursor/offset — so "page through them" would be a second false promise // inside the remedy for the first one (codex gate). Say what is actually true. return need <= LIMIT_CEILING ? `list all with ${call}` : `list the first ${LIMIT_CEILING} with ${call}; there is no cursor/offset, so enumerate the rest by re-running that query with \`types\`/\`where\` narrowed`; }; if (refEntries.length > REF_CAP) upRefs["…"] = `+${refEntries.length - REF_CAP} more (fixed ${REF_CAP}-ref cap) — ${followUpText("upstream_of", refEntries.length)}`; const allDown = [...(down.get(String(id)) ?? [])]; const downstream = allDown.length > DOWN_CAP ? [ ...allDown.slice(0, DOWN_CAP), `…+${allDown.length - DOWN_CAP} more (fixed ${DOWN_CAP}-consumer cap) — ${followUpText("downstream_of", allDown.length)}`, ] : allDown; const title = n._meta?.title != null ? clip(n._meta.title, 200) : n._meta?.title; line = JSON.stringify({ id, type: clip(n.class_type ?? "?", 200), title, widgets: capWidgets(widgetsOf(n), maxChars), upstream: upRefs, downstream, }); // Final guard: if the fully-capped detail STILL exceeds max_chars (a pathological // high-fan-in node), degrade the protected line to a bounded valid-JSON stub so it // is never dropped yet can't flood — every rendered detail line ends up ≤ max_chars. line = fitDetailLine(line, { id, type: clip(n.class_type ?? "?", 200) }, maxChars); } else { // ONE cap across the row (see compactValueCap above). A per-widget cap that varied // as a budget drained made the clip note incoherent: values cut at 2048, 736 and 60 // were all reported as "clipped to 2048 … which no parameter raises", while raising // `max_chars` demonstrably lifted them (gate). const w = Object.entries(widgetsOf(n)) .map(([k, v]) => { const c = clipFlag(v, compactValueCap); if (c.clipped) rowClips++; return `${k}=${c.text}`; }) .join(" "); const ins = Object.entries(n.inputs ?? {}) .filter(([, v]) => isRef(v)) .map(([k, v]) => `${k}:${v[0]}`) .join(" "); const outs = [...(down.get(String(id)) ?? [])].join(","); line = `#${id} ${n.class_type ?? "?"}${n._meta?.title ? ` "${clip(n._meta.title, 40)}"` : ""}` + (w ? ` · ${w}` : "") + (ins ? ` ← ${ins}` : "") + (outs ? ` → ${outs}` : ""); // #609: bound the compact line by widget COUNT too — the protected first line // bypasses the running budget, so a thousands-of-widgets node would else be // unbounded. Plain string ⇒ a tail ellipsis is safe. line = clipLine(line, maxChars); } const protectedLine = shown === 0; // first match always renders → shown≥1 if (!protectedLine && chars + line.length + 1 > maxChars) { truncated = true; truncatedBy = "max_chars"; break; } chars += line.length + 1; lines.push(line); lineClips.push(rowClips); shown++; } // #809 (defect 1): BOTH cuts above land here, and they have OPPOSITE remedies — // raising `limit` on a char-budget cut only makes the overflow worse. Name the lever // that actually fired, with its REAL ceiling, and say plainly that the other one // will not help, so a caller cannot burn a retry proving the tool "can't do this". // // AND (codex gate): "raise X up to N" is itself a dead retry when the caller is ALREADY // at N. Telling someone at limit=200 to raise limit to 200 is the same wasted round // trip as naming the wrong lever — so at the ceiling the remedy switches to what is // actually left: narrowing, or the aggregate. const buildTail = (by, shownNow) => { if (!by) return ""; const atCeiling = by === "limit" ? limit >= LIMIT_CEILING : maxChars >= MAX_CHARS_CEILING; const param = by === "limit" ? "limit" : "max_chars"; const ceiling = by === "limit" ? LIMIT_CEILING : MAX_CHARS_CEILING; const raise = atCeiling ? `\`${param}\` is already at its ceiling of ${ceiling}, so raising it is not an option` : `raise \`${param}\` up to ${ceiling}`; const other = by === "limit" ? "`max_chars` is not the constraint here." : "Raising `limit` will not help."; const narrow = wantIds?.length ? "request fewer `ids` at once" : 'narrow with `types`/`where`/`ids`/`depth`, or count with `group_by`:"type"'; const cutBy = by === "limit" ? `\`limit\`=${limit}` : `\`max_chars\`=${maxChars}${wantIds?.length ? " (per-field values are already capped)" : ""}`; const subject = wantIds?.length ? " requested node(s)" : ""; return `\n… truncated at ${shownNow} of ${matched.length}${subject} by ${cutBy} — ${raise}, or ${narrow}. ${other}`; }; // #809: the compact projection's 60-char value clip is FIXED — no parameter lifts it, // so point at the projection that carries fuller values instead of at a dead lever. const buildClipNote = (n) => { if (n <= 0) return ""; // #1634: the cap is uniform across the row and is only ever the survey clip or the // fixed per-value cap, so the note has exactly two honest forms. An intermediate, // budget-derived cap was tried and removed: it made the note name a number that was in // force for no widget, and called a cut "unraisable" that raising `max_chars` lifted. if (compactValueCap <= COMPACT_VALUE_CLIP) return `\n(${n} widget value(s) clipped to ${COMPACT_VALUE_CLIP} chars by \`fields\`:"compact" — read fuller values with \`fields\`:"detail", which caps values at ${WIDGET_VALUE_CAP} chars.)`; return `\n(${n} widget value(s) clipped to ${WIDGET_VALUE_CAP} chars — the same fixed per-value cap \`fields\`:"detail" applies, which no parameter raises.)`; }; const assemble = () => { const body = fields === "ids" ? lines.join(",") : lines.join("\n"); return `${header}\n${body}${buildTail(truncatedBy, shown)}${buildClipNote(lineClips.reduce((x, y) => x + y, 0))}`; }; // #809 (codex gate): the tail and the footer are part of the RESULT and must fit inside // `max_chars`, not be appended past it. Reserving their worst case UP FRONT was wrong — // at the 500 floor it pre-spent the whole budget and manufactured a truncation on a // result that fitted, which is the false-truncation defect this issue also exists to // remove. So fit AFTERWARDS instead: drop rows from the end, exactly as many as needed. // The first row is never dropped (the documented #609 protection), so a single // pathological row can still exceed the budget — that exception is unchanged. let text = assemble(); while (text.length > maxChars && lines.length > 1) { lines.pop(); lineClips.pop(); shown--; truncated = true; truncatedBy = "max_chars"; text = assemble(); } return { total, candidates: candidates.length, matched: matched.length, shown, truncated, truncated_by: truncatedBy, text, }; } //# sourceMappingURL=graph-query.js.map