SLOPSHOPPER

ciel-mode

ciel-mode (Call-in-CEL mode) for Claude Code: the model writes a small sandboxed CEL program that calls MCP and read-only tools, filters and joins their…

newguardcommandtool
★ 1v0.1.1Unlicense AND MITupdated 2026-10-09ruihe774/cc-ciel-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ciel-mode
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ciel-mode ⎿ ciel-mode: Error: program is required ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ciel-mode

A Claude Mod that gives Claude Code ciel-mode (Call-in-CEL mode): instead of calling an MCP tool and reading its whole output, Claude writes a small program that calls the tools, filters, ranks and joins their results, and returns only the answer. A 100 KB issue list becomes {"stale": 90, "labels": {"docs": 24, ...}} before it reaches the context.

Programs are written in CEL (Common Expression Language) and run in a sandbox. Unlike Code Mode setups that run JavaScript or Python, a program here can't touch the file system, the network, processes or the host, so it does nothing but compute and call tools. Every tool call it makes goes back through Claude Code, so permission rules and other mods' hooks apply to it as they do to Claude's own calls, and a call your rules would ask about needs your approval, given once for the program before it runs.

Why

MCP tools often return far more than the task needs: every issue in a repository, every row of a query, a whole document. Claude reads all of it, and on a long task the context fills with data it looked at once. The Claude API has programmatic tool calling and Codex and Cloudflare have Code Mode for this; community MCP wrappers do it by running scripts in a proxy that holds its own copies of your MCP servers and credentials. This mod does it inside Claude Code, with the MCP servers Claude Code already has connected, and with a language that can't do harm.

What Claude gets

ToolWhat it does
mcp__ciel-mode__runRuns a program and returns its result.
mcp__ciel-mode__toolsLists the tools a program may call, one line each, optionally filtered. Claude loads a tool's full description and input schema with ToolSearch, Claude Code's own schema loader.

And for you, /ciel-mode <program> runs a program by hand.

A program is let lines and a final expression:

let issues = call("mcp__github__list_issues", {"owner": "o", "repo": "r", "state": "open"})
let stale = issues.filter(i, i.updated_at < "2026-01-01")
let top = stale.sortBy(i, -i.comments).take(3)
let details = callEach("mcp__github__get_issue", top.map(i, {"owner": "o", "repo": "r", "issue_number": i.number}))
{"stale": size(stale),
 "labels": stale.map(i, i.labels.map(l, l.name)).flatten().countBy().take(5),
 "top": details.map(d, {"n": d.number, "assignee": d.?assignee.?login.orValue("none")})}
  • call(tool, args) returns the tool's output as data (JSON parsed, else text). A failed call stops the program with the tool's error and the line it was on.
  • tryCall(tool, args) returns {ok, value, error} and never stops the program.
  • callEach(tool, [args, ...]) makes the calls in parallel and keeps their order.
  • Standard CEL (filter, map, exists, all, has, optional fields x.?f.orValue(d), string functions), plus helpers for data work: take, drop, reverse, sort, sortBy, distinct, flatten, groupBy, countBy, sum, min, max, keys, values, replace, find, findAll, lines, truncate, json, toJson. sortBy, groupBy and countBy take a key per element (xs.sortBy(x, -x.n)) or a field name (xs.sortBy("-n")).
  • var in place of let keeps a value for the session's later programs, in memory: var issues = call(...) in one program, issues.filter(...) in the next, with no second call. Another var issues = ... line replaces it, var issues = null drops it, and vars() lists what is kept. A let lasts one program. A var is kept as soon as its line runs, so a later failure in the same program doesn't lose it. At most max_vars are kept at once; the session's end (and /clear, /resume) drops them all.
  • The whole program is parsed and type-checked before anything runs, so a typo costs no tool calls. Errors name the line, and the common mistakes get a hint.
  • Outputs that Claude Code would save to a file because they are too large come back whole inside the program.

Approval

When a program names a tool that your permission rules would ask about, ciel-mode shows one dialog before the program starts, listing every such tool:

A ciel-mode program wants to call these tools, which need your approval: mcp__gmail__send_message, mcp__github__create_issue. Let this program call them with any arguments?
❯ 1. Run the program
  2. Don't run it
  • Approval covers that program only, and only the tools it names with string literals (call("mcp__gmail__send_message", ...)). A call to a tool named any other way (a computed string) that needs approval is refused, since a running program can't stop to ask.
  • Tools your rules already allow run without a dialog; tools they deny stay denied, approved or not.
  • With no one to ask (claude -p) or the dialog dismissed, the program doesn't run, and Claude is told to make the calls directly instead.
  • A program you type yourself with /ciel-mode counts as approved for the tools it names.
  • always_allow lists tools programs may call without the dialog.

This holds in every permission mode. It matters most in auto mode: Claude Code doesn't put a plugin's tool calls to its auto-mode classifier and lets them run (its debug log says Skipping auto mode classifier for Bash: called by plugin ciel-mode), so without the approval step a program could do in auto mode what the classifier would have blocked had Claude made the call itself.

Settings

OptionDefaultWhat it does
tools`^(mcp__.+\Read\Glob\Grep\WebFetch\WebSearch)$`A regex over full tool names: the tools a program may call. ciel-mode's own tools are never callable, so a program can't start another. Widen it (to Bash, Write, ...) only on purpose.
always_allowemptyA regex over full tool names: tools a program may call without the approval dialog, even when your rules would ask. Empty means none.
deny_directoffDeny Claude's direct calls to callable MCP tools, pointing it to run. A program's calls still pass. Built-in tools are never denied.
max_calls100Tool calls one program may make; the program is stopped past it. callEach is checked before it starts.
concurrency8Calls callEach runs at once.
max_output20000Characters of a result Claude reads; the rest is cut with a note.
max_vars20var values kept for the session at once; a program that would keep more is stopped before it runs. 0 turns var off.

Set them in /plugin, or under pluginConfigs in your settings.

Try deny_direct if Claude keeps calling the tools directly. By default Claude chooses, and smaller models often call a big tool directly once first, which brings its output (or Claude Code's notice that it saved the output to a file) into the context and costs a turn.

Measured

e2e/run.sh asks claude-haiku-5-5 three questions about a mock issue tracker (300 issues) three ways: without ciel-mode, with it, and with it and deny_direct. Every answer in the runs below was correct. Input tokens summed over each run's requests, for three runs each:

QuestionWithout ciel-modeciel-modeciel-mode + deny_direct
Q1: count open stale issues and their top labels (one 81 KB list)74k, 94k, 74k61k, 83k, 252k61k, 82k, 81k
Q2: top 5 issues by comments, then each one's commenters (list + 5 calls)104k, 103k, 103k200k, 199k, 153k172k, 149k, 159k
Q3: top 3 commenters across 40 full threads (40 calls, 115 KB in all)96k, 96k, 96k82k, 103k, 83k82k, 124k, 125k

What this shows:

  • ciel-mode saves tokens when one program does the job and the data would otherwise land in the context. In Q3 each output is under Claude Code's size limits, so without ciel-mode all 115 KB is read inline; a program that gets it right the first time uses about 15% less. In a longer session the saving grows, since data in the context is re-read on every later request.
  • It costs tokens when the data would not have landed anyway, or when the program takes several tries. Claude Code already saves an output over its size limit to a file; in Q1 and Q2 the baseline read that file with one Bash+Python command (which works, but runs an unsandboxed script). Most of each request is Claude Code's own system prompt and tools, so every extra request costs about as much as the data saved, and Haiku often needs two or three attempts at a CEL program. The one 252k run called the tool directly first, then tried Bash, Read and a program over the file Claude Code saved the output to before it wrote the program that answered.
  • deny_direct removes the direct first call that otherwise brings the output, or Claude Code's notice for it, into the context.

A stronger model writes the program right the first time more often. The error hints and the lambda forms (sortBy(x, key)) were added for the mistakes Haiku made in these runs.

Safety

  • Sandbox. CEL has no I/O, no loops beyond list macros, and no access to the host beyond the functions this mod registers. Field access reads only a value's own fields (x.__proto__ and x.constructor are missing keys), tool data is copied without prototypes, and parse limits cap a program's size and nesting.
  • Same rules as Claude's own calls, plus approval. Every call goes through $.tool.call: permission rules, managed and settings hooks, and every other mod's tool.call hooks. A call the rules would ask about runs only if you approved its tool for the program (see Approval); ciel-mode decides this itself in a tool.check hook, because Claude Code would otherwise let a plugin's call run unreviewed in auto mode. A program can't set the keys Claude Code reserves on a call (such as consent, which speaks for the user to the permission check).
  • Scope. By default only MCP tools and the read-only built-ins (Read, Glob, Grep, WebFetch, WebSearch) are callable. A tool named by a string literal is checked before the program runs; one named by a computed string, when it is called.
  • Limits. max_calls and max_output bound a run, and an interrupt stops a program before its next call. max_vars bounds how many values programs keep in memory between runs (not their size).
  • Spilled output. A result Claude Code saved to a file is read back only from this session's tool-results folder under Claude Code's config dir, so a tool can't point ciel-mode at another file by returning text that looks like Claude Code's notice.

Approving a tool for a program lets that program call it with any arguments, as many times as max_calls allows, including MCP tools that write (send a message, create an issue). Read the program in the transcript before you approve it, and keep always_allow to tools whose every call you would allow.

Installation

Requires Claude Code v2.1.287 or later. Load it from a checkout:

claude --plugin-dir /path/to/ciel-mode

Development

  • claude plugin validate . and claude plugin test (unit and test-kit tests in tests/)
  • Typecheck: load once with claude --plugin-dir . to generate .claude-plugin/types/, then npx -p typescript tsc -p .
  • e2e/run.sh quick runs the deterministic end-to-end checks against a real Claude Code; e2e/run.sh adds the model runs. See CLAUDE.md.

License & Acknowledgements

Unlicense. Bundles cel-js (MIT); see THIRD_PARTY_NOTICES.md.

kzarzycki's eval-kernel was the first Claude mod implemnting a tool call executor. It used a standalone Bun process.

Source 7 files
hooks/register.ts 232 lines
1// ciel-mode: the model writes a small CEL program that calls tools, filters and joins
2// their results, and returns only what it needs. Programs run in a sandbox (CEL can
3// reach nothing but the functions program.ts gives it); every tool call goes back
4// through $.tool.call, so permission checks and other mods' hooks still apply.
5import { APPROVE, DECLINE, Pending, approvalQuestion, callKey, unapprovedReason } from './approval.ts'
6import { OWN_PREFIX, PLUGIN, alwaysOf, index, scopeOf, scopeText } from './catalog.ts'
7import { runProgram, type CallOutcome, type RunResult, type VarStore } from './program.ts'
8import { spilledFile, spilledText } from './spill.ts'
9
10const RUN = 'run'
11const TOOLS = 'tools'
12
13// Claude Code passes a registered tool's description to the model up to its first 2048
14// characters, so the description says what the tool is for and the language reference
15// goes in the description of its `program` parameter.
16export function runDescription(scope: string, maxOutput: number): string {
17  return `Run a small program that calls tools and returns only its result, so big tool outputs never reach you. Prefer it to calling a tool directly when the output may be large (lists, search results, big JSON) and you need only part of it, when you would make many similar calls, or when you need to count, rank or join results. Do the whole job in one program when you can (fetch, filter, rank, fan out, aggregate) and return just the answer, a few hundred characters, not raw records.
18
19A program is sandboxed CEL: \`let <name> = <expression>\` lines, then a last expression, the result. \`var\` in place of \`let\` keeps a value for later programs (\`var x = null\` drops it). CEL has no assignment, mutation or loops; build values with .map/.filter and the helpers. The \`program\` parameter's description has the full reference: read it before writing one.
20
21- \`call("<tool>", {"arg": value})\` returns the tool's output as data (JSON parsed, else text; never content blocks or a "saved to a file" notice, whatever its size). A failed call stops the program.
22- \`tryCall("<tool>", {...})\` returns \`{"ok", "value", "error"}\` and never stops it.
23- \`callEach("<tool>", [{...}, ...])\` calls in parallel; outputs in order.
24Callable: ${scope} (${OWN_PREFIX}${TOOLS} lists them). Use argument names from the tool's input schema; load it with ToolSearch ("select:<tool>") if you lack it. Name tools with string literals: before the program runs, the user approves the ones that need approval, and a call to a tool named any other way that needs approval is refused.
25
26Example:
27let issues = call("mcp__github__list_issues", {"owner": "o", "repo": "r", "state": "open"})
28let stale = issues.filter(i, i.updated_at < "2026-01-01")
29{"stale": size(stale), "labels": stale.map(i, i.labels.map(l, l.name)).flatten().countBy().take(5), "oldest": stale.sortBy(i, i.updated_at).take(3).map(i, i.number)}
30
31Results over ${maxOutput} characters are cut.`
32}
33
34/** The language reference, as the description of the `program` parameter. */
35export const PROGRAM_REFERENCE = `The program: \`let <name> = <expression>\` lines, then the result expression (a string as it is, anything else as compact JSON). An expression continues onto the next lines while a bracket is open or a line starts with \`.\` or an operator. \`//\` comments.
36
37\`var <name> = <expression>\` binds like \`let\` and also keeps the value for this session's later programs, which read it by name (as dyn) and can replace it with another \`var\` line. It is kept as soon as its line runs, even if a later line fails. Keep fetched data there to filter it again without calling the tool again. \`var <name> = null\` drops it; the number kept at once is capped. \`vars()\` lists the names kept.
38
39Recipes:
40- See an output's shape (don't call a big tool directly for that): \`call("<tool>", {...}).take(2)\`, \`call(...).keys()\` for a map, \`toJson(x).truncate(500)\`
41- Count and rank: \`items.map(i, i.labels).flatten().countBy()\` gives \`{"docs": 24, "ui": 19}\`, most frequent first; \`items.countBy(i, i.author)\` counts by a key
42- Top N: \`items.sortBy(i, -i.comments).take(5).map(i, {"n": i.number, "title": i.title})\`
43- Fan out: \`callEach("<tool>", ids.map(id, {"id": id})).map(r, r.title)\`
44- Several answers at once: return a map, \`{"count": size(xs), "top": ...}\`
45
46CEL reference:
47- Operators \`== != < <= > >= && || ! ?: in + - * / %\` (\`+\` joins strings and lists), \`size(x)\`, \`string(x) int(x) double(x)\`, literals \`[1, 2]\` \`{"k": v}\`, \`cel.bind(name, value, expr)\` to name a value inside an expression. Whole numbers are ints: \`1\`, not \`1.0\`.
48- Lists: \`.filter(x, cond)\` \`.map(x, expr)\` \`.map(x, cond, expr)\` \`.exists(x, cond)\` \`.all(x, cond)\` \`.exists_one(x, cond)\` \`l[0]\` \`.join(sep)\` \`.take(n) .drop(n) .reverse() .sort() .distinct() .flatten() .sum() .min() .max()\`, and with a key per element: \`.sortBy(x, key)\` (ascending; \`-key\` for descending numbers, else add \`.reverse()\`), \`.groupBy(x, key)\` (key to elements), \`.countBy(x, key)\` (key to count, most frequent first), \`.countBy()\` (counts the elements). A field name works as the key: \`.sortBy("updated_at")\`, \`.sortBy("-comments")\`, \`.countBy("user.login")\`
49- Maps: \`m.key\` \`m["key"]\` \`m.keys()\` \`m.values()\` \`m.take(n)\` (first n entries); a missing key is an error, so for optional fields use \`has(m.key)\` or \`m.?key.orValue(default)\`
50- Strings: \`.contains .startsWith .endsWith .matches(re) .lowerAscii() .upperAscii() .trim() .split(sep) .substring(i, j) .indexOf(s) .replace(old, new) .find(re) .findAll(re) .lines() .truncate(n)\`; \`json(text)\` parses, \`toJson(value)\` prints
51
52Example, fetching details of the top 3 in parallel:
53let top = call("mcp__github__list_issues", {"owner": "o", "repo": "r"}).sortBy(i, -i.comments).take(3)
54let details = callEach("mcp__github__get_issue", top.map(i, {"owner": "o", "repo": "r", "issue_number": i.number}))
55details.map(d, {"n": d.number, "assignee": d.?assignee.?login.orValue("none")})`
56
57// Programs' calls on their way to tool.check, and among them the calls of approved tools,
58// whose `ask` becomes `allow`. ciel-mode's other calls (the approval dialog) aren't in them.
59const inFlight = new Pending()
60const approvedCalls = new Pending()
61
62// The session's vars, from `var` lines: in memory only, emptied when the session ends
63// (including /clear and /resume)
64const kept = new Map<string, unknown>()
65
66/** One tool call from a program, through every hook and the permission check. A call
67 *  of an approved tool is marked, so tool.check lets it run. */
68async function callTool($: any, tool: string, args: Record<string, unknown>, approved: boolean): Promise<CallOutcome> {
69  let r: any
70  const key = callKey(tool, args)
71  inFlight.add(key)
72  if (approved) approvedCalls.add(key)
73  try {
74    r = await $.tool.call({ ...args, tool })
75  } catch (e) {
76    return { ok: false, error: String((e as Error)?.message ?? e) }
77  } finally {
78    inFlight.delete(key)
79    if (approved) approvedCalls.delete(key)
80  }
81  if (typeof r?.deny === 'string') return { ok: false, error: `refused: ${r.deny}` }
82  const text = typeof r?.text === 'string' ? r.text : typeof r?.result === 'string' ? r.result : JSON.stringify(r?.result ?? null)
83  if (r?.isError === true) return { ok: false, error: text }
84  // Output over Claude Code's size limit comes back as a notice naming the file it was saved to
85  const file = spilledFile(text, await configDir($), await $.session.id())
86  if (file === undefined) return { ok: true, text }
87  try {
88    return { ok: true, text: spilledText(file, await $.fs.read(file)) }
89  } catch (e) {
90    return { ok: false, error: `the output was too large and could not be read back from ${file}: ${String((e as Error)?.message ?? e)}` }
91  }
92}
93
94/** Claude Code's config dir, where it saves oversized tool results. */
95async function configDir($: any): Promise<string> {
96  const dir = await $.env.get('CLAUDE_CONFIG_DIR')
97  if (dir) return dir
98  return `${((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, '')}/.claude`
99}
100
101/** The tools among `tools` whose calls need the user's approval: those the permission
102 *  rules would ask about, and that `always_allow` doesn't cover. */
103async function needingApproval($: any, tools: readonly string[], options: Settings): Promise<string[]> {
104  const out: string[] = []
105  for (const tool of tools) {
106    if (options.always(tool)) continue
107    let decision = 'ask'
108    try {
109      decision = (await $.tool.check({ tool, input: {} })).decision
110    } catch {}
111    if (decision === 'ask') out.push(tool)
112  }
113  return out
114}
115
116/** Runs a program with this session's settings. Before its first call, the user approves
117 *  the tools it names that need approval: in a dialog, or by having typed the program
118 *  (`byUser`, /ciel-mode). */
119async function run($: any, program: unknown, options: Settings, signal: AbortSignal | undefined, byUser: boolean): Promise<RunResult> {
120  if (typeof program !== 'string' || !program.trim()) return { ok: false, error: 'program is required', calls: 0 }
121  const approved = new Set<string>()
122  const approve = async (tools: readonly string[]): Promise<string | null> => {
123    const ask = await needingApproval($, tools, options)
124    if (ask.length && !byUser) {
125      let answer = ''
126      try {
127        answer = await $.ui.ask(approvalQuestion(ask), { options: [APPROVE, DECLINE], header: 'ciel-mode' })
128      } catch {
129        return `the user's approval is needed to call ${ask.join(', ')}, and none was given (the dialog was dismissed, or no one can be asked). Call ${ask.length === 1 ? 'it' : 'them'} directly instead.`
130      }
131      if (answer !== APPROVE) return `the user declined to run this program${answer && answer !== DECLINE ? `: ${answer}` : ''}`
132    }
133    for (const tool of ask) approved.add(tool)
134    return null
135  }
136  const host = { call: (tool: string, args: Record<string, unknown>) => callTool($, tool, args, approved.has(tool) || options.always(tool)) }
137  const vars: VarStore = { values: kept, max: options.maxVars }
138  return runProgram(program, options.scope, host, { ...options, signal, approve, vars })
139}
140
141interface Settings {
142  pattern?: string
143  scope: (tool: string) => boolean
144  always: (tool: string) => boolean
145  denyDirect: boolean
146  maxCalls: number
147  concurrency: number
148  maxOutput: number
149  maxVars: number
150}
151
152const positive = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 1 ? Math.floor(v) : fallback)
153
154export function settings(options: Record<string, unknown> | undefined): Settings {
155  const pattern = typeof options?.tools === 'string' ? options.tools : undefined
156  return {
157    pattern,
158    scope: scopeOf(pattern),
159    always: alwaysOf(typeof options?.always_allow === 'string' ? options.always_allow : undefined),
160    denyDirect: options?.deny_direct === true,
161    maxCalls: positive(options?.max_calls, 100),
162    concurrency: positive(options?.concurrency, 8),
163    maxOutput: positive(options?.max_output, 20000),
164    maxVars: typeof options?.max_vars === 'number' && Number.isFinite(options.max_vars) && options.max_vars >= 0 ? Math.floor(options.max_vars) : 20,
165  }
166}
167
168export function register(on: any, options?: Record<string, unknown>) {
169  const s = settings(options)
170
171  on('session.start', async ($: any, e: any, next: any) => {
172    await $.tool.register({
173      name: RUN,
174      description: runDescription(scopeText(s.pattern), s.maxOutput),
175      inputSchema: {
176        type: 'object',
177        properties: { program: { type: 'string', description: PROGRAM_REFERENCE } },
178        required: ['program'],
179      },
180      isDeferred: false,
181    })
182    await $.tool.register({
183      name: TOOLS,
184      description: `List the tools a ${OWN_PREFIX}${RUN} program can call, one line each. \`query\` narrows the list (a case-insensitive regex over names and descriptions).`,
185      inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Optional filter, e.g. "github|issue"' } } },
186      isDeferred: false,
187    })
188    await $.command.register({ name: PLUGIN, description: 'Run a ciel-mode program yourself: `let` lines, then the result expression', argumentHint: '<program>' })
189    return next(e)
190  })
191
192  on('session.end', async ($: any, e: any, next: any) => {
193    kept.clear()
194    return next(e)
195  })
196
197  on('tool.call', { tool: 'mcp__ciel-mode__run' }, async ($: any, e: any, next: any) => {
198    const r = await run($, e.program, s, next.signal, false)
199    return r.ok ? { result: r.output } : { isError: true, result: `Error: ${r.error}` }
200  }).catch(async ($: any, e: any, next: any) => ({ isError: true, result: `Error: the program was stopped (${next.error.kind}: ${next.error.message})` }))
201
202  on('tool.call', { tool: 'mcp__ciel-mode__tools' }, async ($: any, e: any) => {
203    return { result: index(await $.tool.list(), s.scope, typeof e.query === 'string' ? e.query : undefined) }
204  })
205
206  // A program's call that the permission rules would ask about runs only when its tool was
207  // approved for the program (or is always allowed). Claude Code doesn't put a plugin's
208  // call to the auto-mode classifier, so without this it would run unasked in auto mode.
209  on('tool.check', async ($: any, e: any, next: any) => {
210    const r = await next(e)
211    if (next.origin?.plugin !== PLUGIN || !e.tool_use_id || r?.decision !== 'ask') return r
212    const key = callKey(e.tool, e.input)
213    if (!inFlight.has(key)) return r
214    if (approvedCalls.has(key)) return { ...r, decision: 'allow', reason: `approved for this ciel-mode program` }
215    return { decision: 'deny', reason: unapprovedReason(e.tool) }
216  }).catch(async ($: any, e: any, next: any) => ({ decision: 'deny', reason: `ciel-mode could not check this call (${next.error.message})` }))
217
218  // Opt-in: the model's own calls to callable MCP tools are turned away to a program.
219  // The calls a program makes are this plugin's, and pass.
220  on('tool.call', { tool: /^mcp__/ }, async ($: any, e: any, next: any) => {
221    if (!s.denyDirect || next.origin?.plugin === PLUGIN || e.tool.startsWith(OWN_PREFIX) || !s.scope(e.tool)) return next(e)
222    return {
223      deny: `Call ${e.tool} from a program instead: ${OWN_PREFIX}${RUN} with \`call("${e.tool}", {...})\`, returning only what you need.`,
224    }
225  })
226
227  on('command.run', { command: 'ciel-mode' }, async ($: any, e: any, next: any) => {
228    const r = await run($, e.args, s, next.signal, true)
229    return r.ok ? { text: r.output } : { text: `Error: ${r.error}`, exitCode: 1 }
230  })
231}
232
hooks/approval.ts 50 lines
1// Approval of a program's calls. Claude Code skips the auto-mode classifier for a call a
2// plugin raises and lets it run, so ciel-mode decides such calls itself: a call whose
3// permission decision is `ask` runs only when the user approved its tool for this
4// program, in one dialog before the program starts, or allows it always (`always_allow`).
5// Every other `ask` is refused. Pure and free of the mods API; register.ts asks and checks.
6
7/** JSON with keys in a fixed order, so equal inputs give equal text. */
8export function stableJson(v: unknown): string {
9  if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`
10  if (v && typeof v === 'object')
11    return `{${Object.keys(v)
12      .sort()
13      .map((k) => `${JSON.stringify(k)}:${stableJson((v as Record<string, unknown>)[k])}`)
14      .join(',')}}`
15  return JSON.stringify(v) ?? 'null'
16}
17
18/** One call, as the program makes it and as `tool.check` sees it. */
19export const callKey = (tool: string, input: unknown) => `${tool}\u0000${stableJson(input ?? {})}`
20
21/** The calls of approved tools that are on their way to `tool.check`, counted, so two
22 *  identical calls in flight are two entries. */
23export class Pending {
24  private readonly counts = new Map<string, number>()
25  add(key: string) {
26    this.counts.set(key, (this.counts.get(key) ?? 0) + 1)
27  }
28  delete(key: string) {
29    const n = (this.counts.get(key) ?? 0) - 1
30    if (n > 0) this.counts.set(key, n)
31    else this.counts.delete(key)
32  }
33  has(key: string) {
34    return this.counts.has(key)
35  }
36}
37
38/** The question of the approval dialog. */
39export function approvalQuestion(tools: readonly string[]): string {
40  return `A ciel-mode program wants to call ${tools.length === 1 ? 'this tool, which needs' : 'these tools, which need'} your approval: ${tools.join(', ')}. Let this program call ${tools.length === 1 ? 'it' : 'them'} with any arguments?`
41}
42
43export const APPROVE = 'Run the program'
44export const DECLINE = "Don't run it"
45
46/** What Claude reads when a call needing approval was not approved before the program ran. */
47export function unapprovedReason(tool: string): string {
48  return `${tool} needs approval, and a running program can't ask for it. Name the tool with a string literal (call("${tool}", ...)) so it is approved before the program starts, or call it directly.`
49}
50
hooks/catalog.ts 68 lines
1// Which tools a program may call, and the one-line index the `tools` tool shows.
2// Pure and free of the mods API, so it can be unit tested.
3import type { Scope } from './program.ts'
4
5export const PLUGIN = 'ciel-mode'
6export const OWN_PREFIX = `mcp__${PLUGIN}__`
7export const DEFAULT_TOOLS = '^(mcp__.+|Read|Glob|Grep|WebFetch|WebSearch)$'
8
9/** The scope a `tools` pattern (a regex over full tool names) allows. This mod's own
10 *  tools are never in it, so a program can't start another program. */
11export function scopeOf(pattern: string | undefined): Scope {
12  let re: RegExp
13  try {
14    re = new RegExp(pattern?.trim() || DEFAULT_TOOLS)
15  } catch {
16    // A broken pattern in the settings allows nothing, rather than everything
17    return () => false
18  }
19  return (tool) => !tool.startsWith(OWN_PREFIX) && re.test(tool)
20}
21
22/** The tools a program may call without asking the user (`always_allow`, a regex over
23 *  full tool names): none when it is empty or broken. */
24export function alwaysOf(pattern: string | undefined): (tool: string) => boolean {
25  const p = pattern?.trim()
26  if (!p) return () => false
27  try {
28    const re = new RegExp(p)
29    return (tool) => re.test(tool)
30  } catch {
31    return () => false
32  }
33}
34
35/** Plain-language list of what a scope covers, for the tool descriptions. */
36export function scopeText(pattern: string | undefined): string {
37  const p = pattern?.trim()
38  return !p || p === DEFAULT_TOOLS ? 'MCP tools, and Read, Glob, Grep, WebFetch and WebSearch' : `tools whose names match /${p}/`
39}
40
41export interface ToolInfo {
42  name: string
43  description: string
44  mcp: boolean
45}
46
47const firstLine = (s: string) => s.trim().split(/\n/)[0]!.trim()
48const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
49
50/** One line per callable tool that matches `query` (a case-insensitive regex, or plain
51 *  text when it isn't one), matched against the name and description. */
52export function index(tools: readonly ToolInfo[], scope: Scope, query?: string): string {
53  let match: (t: ToolInfo) => boolean = () => true
54  const q = query?.trim()
55  if (q) {
56    let re: RegExp
57    try {
58      re = new RegExp(q, 'i')
59    } catch {
60      re = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
61    }
62    match = (t) => re.test(t.name) || re.test(t.description)
63  }
64  const lines = tools.filter((t) => scope(t.name) && match(t)).map((t) => `${t.name}: ${cut(firstLine(t.description), 140)}`)
65  if (!lines.length) return q ? `No callable tool matches ${JSON.stringify(q)}.` : 'No callable tools.'
66  return `${lines.join('\n')}\n\nLoad a tool's full description and input schema with ToolSearch ("select:<name>") before calling it with arguments you are unsure of.`
67}
68
hooks/program.ts 443 lines
1// Programs: `let name = <CEL>` and `var name = <CEL>` statements and a final CEL
2// expression, compiled (parsed and type-checked) as a whole before anything runs, then
3// run against an injected host that makes the tool calls. A `var` outlives its run in a
4// store the caller keeps. Pure and free of the mods API, so it
5// can be unit tested; register.ts supplies the host.
6import { baseEnv, fromJson, render, toJson } from './stdlib.ts'
7import type { Environment } from './vendor/cel/cel.js'
8
9// ---- Parsing: statements from lines ----
10
11export interface Statement {
12  line: number // 1-based line the statement starts on
13  name?: string // the bound name; absent for the final expression
14  kind?: 'let' | 'var' // how it is bound; absent for the final expression
15  source: string // the CEL expression
16}
17
18const BIND = /^\s*(let|var)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)/
19const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/
20// A line that starts with one of these continues the statement above it
21const LEADING_OP = /^\s*(\.|\?|:|&&|\|\||\+|\*|\/|%|==|!=|<|>|in\s|\]|\)|\})/
22// A statement that ends with one of these continues on the next line
23const TRAILING_OP = /(\.|\?|:|&&|\|\||\+|-|\*|\/|%|==|!=|<=|>=|<|>|,|\(|\[|\{|\bin)\s*$/
24
25/** Where a line leaves a statement: open brackets, an open triple-quoted string, and the
26 *  line without its `//` comment. Strings are skipped so their brackets don't count. */
27function scan(line: string, state: { depth: number; triple: string | null }): string {
28  let out = ''
29  let i = 0
30  while (i < line.length) {
31    if (state.triple) {
32      const end = line.indexOf(state.triple, i)
33      if (end < 0) return out + line.slice(i)
34      out += line.slice(i, end + 3)
35      i = end + 3
36      state.triple = null
37      continue
38    }
39    const c = line[i]!
40    if (c === '/' && line[i + 1] === '/') break
41    if (c === '"' || c === "'") {
42      if (line.startsWith(c.repeat(3), i)) {
43        state.triple = c.repeat(3)
44        out += state.triple
45        i += 3
46        continue
47      }
48      // A one-line string: up to its closing quote, past escapes (raw strings have none
49      // that matter here, since \" can't close one either)
50      let j = i + 1
51      while (j < line.length && line[j] !== c) j += line[j] === '\\' ? 2 : 1
52      out += line.slice(i, j + 1)
53      i = j + 1
54      continue
55    }
56    if ('([{'.includes(c)) state.depth++
57    else if (')]}'.includes(c)) state.depth--
58    out += c
59    i++
60  }
61  return out
62}
63
64export class ProgramError extends Error {
65  constructor(
66    message: string,
67    readonly line?: number,
68  ) {
69    super(line === undefined ? message : `line ${line}: ${message}`)
70  }
71}
72
73/** Splits a program into statements. A line starting `let x =` or `var x =` begins a binding;
74 *  any other line begins the final expression, unless the statement above is still
75 *  open (an open bracket or string, or a trailing operator) or the line starts with
76 *  an operator or a `.`, in which case it continues that statement. */
77export function parseProgram(text: string): Statement[] {
78  const stmts: (Statement & { open: boolean })[] = []
79  const state = { depth: 0, triple: null as string | null }
80  const lines = text.split(/\r?\n/)
81  for (let i = 0; i < lines.length; i++) {
82    const raw = lines[i]!
83    const cur = stmts.at(-1)
84    const inside = !!cur && (state.depth > 0 || !!state.triple || cur.open)
85    if (!inside && !raw.trim()) continue
86    if (!inside && !raw.trim().startsWith('//')) {
87      const m = BIND.exec(raw)
88      if (m || !cur || !LEADING_OP.test(raw)) {
89        if (m && (m[2] === 'let' || m[2] === 'var')) throw new ProgramError(`"${m[2]}" is not a name`, i + 1)
90        const body = m ? raw.slice(m[0].length) : raw
91        const code = scan(body, state)
92        const bind = m ? { name: m[2]!, kind: m[1] as 'let' | 'var' } : {}
93        stmts.push({ line: i + 1, ...bind, source: body, open: TRAILING_OP.test(code) || (!!m && !code.trim()) })
94        continue
95      }
96    }
97    if (!cur) continue // a comment before the first statement
98    const code = scan(raw, state)
99    cur.source += '\n' + raw
100    if (code.trim()) cur.open = TRAILING_OP.test(code)
101  }
102  if (!stmts.length) throw new ProgramError('the program is empty')
103  stmts.forEach((s, i) => {
104    if (!s.source.trim()) throw new ProgramError(`${s.kind} ${s.name} has no expression`, s.line)
105    if (s.name === undefined && i < stmts.length - 1)
106      throw new ProgramError(
107        'only the last statement can be a bare expression; start this one with `let <name> =` (or `var`), or join it to the line above',
108        s.line,
109      )
110  })
111  return stmts.map(({ open: _, ...s }) => s)
112}
113
114// ---- Lambda forms: sugar over map ----
115
116const LAMBDA = /\.(sortBy|sort|groupBy|countBy)\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,/y
117
118/** The index just past the bracket that closes the one opened before `from`, skipping
119 *  strings; -1 when it never closes. */
120function closing(src: string, from: number): number {
121  let depth = 1
122  for (let i = from; i < src.length; i++) {
123    const c = src[i]!
124    if (c === '"' || c === "'") {
125      const q = src.startsWith(c.repeat(3), i) ? c.repeat(3) : c
126      let j = i + q.length
127      while (j < src.length && !src.startsWith(q, j)) j += src[j] === '\\' ? 2 : 1
128      i = j + q.length - 1
129    } else if (c === '/' && src[i + 1] === '/') {
130      while (i < src.length && src[i] !== '\n') i++
131    } else if ('([{'.includes(c)) depth++
132    else if (')]}'.includes(c) && --depth === 0) return i + 1
133  }
134  return -1
135}
136
137/** Rewrites the lambda forms of the helpers into map plus the field-less helpers:
138 *  `xs.sortBy(x, key)` (or `xs.sort(x, key)`) sorts by a computed key, `xs.groupBy(x, key)` and
139 *  `xs.countBy(x, key)` group and count by one. Strings and comments are left alone. */
140export function desugar(src: string): string {
141  let out = ''
142  let i = 0
143  while (i < src.length) {
144    const c = src[i]!
145    if (c === '"' || c === "'") {
146      const q = src.startsWith(c.repeat(3), i) ? c.repeat(3) : c
147      let j = i + q.length
148      while (j < src.length && !src.startsWith(q, j)) j += src[j] === '\\' ? 2 : 1
149      out += src.slice(i, j + q.length)
150      i = j + q.length
151      continue
152    }
153    if (c === '/' && src[i + 1] === '/') {
154      const end = src.indexOf('\n', i)
155      out += end < 0 ? src.slice(i) : src.slice(i, end)
156      i = end < 0 ? src.length : end
157      continue
158    }
159    LAMBDA.lastIndex = i
160    const m = c === '.' ? LAMBDA.exec(src) : null
161    if (m) {
162      const bodyStart = i + m[0].length
163      const end = closing(src, bodyStart)
164      if (end > 0) {
165        const [, fn, v] = m
166        const body = desugar(src.slice(bodyStart, end - 1))
167        out += fn === 'countBy' ? `.map(${v}, ${body}).countBy()` : `.map(${v}, [${body}, ${v}]).${fn === 'groupBy' ? 'groupPairs_' : 'sortPairs_'}()`
168        i = end
169        continue
170      }
171    }
172    out += c
173    i++
174  }
175  return out
176}
177
178// ---- Compiling ----
179
180/** Which tools a program may call. */
181export type Scope = (tool: string) => boolean
182
183/** The vars kept across a session's programs: at most `max` of them, in memory only. */
184export interface VarStore {
185  values: Map<string, unknown>
186  max: number
187}
188
189/** A var's line is `null` and nothing else: it clears the var. */
190const clears = (s: Statement) => s.kind === 'var' && s.source.replace(/\/\/.*$/gm, '').trim() === 'null'
191
192export interface Compiled {
193  statements: readonly { line: number; name?: string; kind?: 'let' | 'var'; fn: (ctx: Record<string, unknown>) => unknown }[]
194  tools: readonly string[] // the tools the program names with string literals, in order
195  run: RunState // the state the host functions read, reset by each run
196}
197
198interface RunState {
199  store: VarStore
200  host?: Host
201  limits?: Limits
202  signal?: AbortSignal
203  calls: number
204}
205
206export interface Limits {
207  maxCalls: number // tool calls per run, across call, tryCall and callEach
208  concurrency: number // calls callEach runs at once
209}
210
211/** The outcome of one tool call: its text, or why it failed or was refused. */
212export type CallOutcome = { ok: true; text: string } | { ok: false; error: string }
213
214export interface Host {
215  call(tool: string, args: Record<string, unknown>): Promise<CallOutcome>
216}
217
218const firstLine = (e: unknown) => String((e as Error)?.message ?? e).split('\n')[0]!
219
220// What to do about the mistakes programs make most, added to their errors
221const HINTS: [RegExp, string][] = [
222  [/Unexpected character: =$|Unexpected token: =/, 'CEL has no assignment or mutation: bind values with `let`, count with .countBy(), group with .groupBy()'],
223  [/No such key: (text|content|structuredContent)$/, 'call() already returns the tool output itself, parsed, not content blocks: use the value directly; see its shape with .take(2) or .keys()'],
224  [/No such key/, 'for a field that may be missing use has(x.field) or x.?field.orValue(default)'],
225  [/overload for '\w+\.(parseJSON|parseJson|toJSON|toJson|json)\(/, 'parse text with json(text), print a value with toJson(value)'],
226  [/overload for 'string\((list|map)/, 'print a list or map with toJson(value)'],
227  [/Reserved identifier: let/, '`let` only starts a line; inside an expression bind a value with cel.bind(name, value, expression)'],
228  [/has\(\) invalid argument/, 'has() takes a field of a name, like has(x.field); bind an element first, e.g. let first = xs[0]'],
229  [/Unknown variable: (for|while|if|return|const|function)\b/, 'CEL has no statements but `let` and `var`: use .map/.filter and `cond ? a : b`'],
230  [/no matching overload/i, 'check the value types; a value from a tool is dyn, so convert with string(x), int(x) or double(x) where needed'],
231]
232const withHint = (message: string) => {
233  const hint = HINTS.find(([re]) => re.test(message))?.[1]
234  return hint ? `${message} (${hint})` : message
235}
236// String literals passed as a tool name: checked against the scope before anything runs
237const LITERAL_TOOL = /\b(?:call|tryCall|callEach)\(\s*(?:"((?:[^"\\\n]|\\.)*)"|'((?:[^'\\\n]|\\.)*)')/g
238
239/** A tool's text as data: JSON when it parses as JSON, else the text itself. */
240export function decode(text: string): unknown {
241  const t = text.trim()
242  if (/^[[{"]|^-?\d|^(true|false|null)$/.test(t)) {
243    try {
244      return fromJson(JSON.parse(t))
245    } catch {}
246  }
247  return text
248}
249
250// Keys of a tool.call input that are the engine's, not the tool's: `consent` would speak
251// for the user to the permission check, so a program may set none of them
252export const RESERVED_ARGS = ['tool', 'tool_use_id', 'consent', 'requestMeta', 'agentId']
253
254function toArgs(v: unknown, tool: string): Record<string, unknown> {
255  const args = toJson(v)
256  if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error(`${tool}: arguments must be a map`)
257  const reserved = RESERVED_ARGS.filter((k) => Object.hasOwn(args, k))
258  if (reserved.length) throw new Error(`${tool}: ${reserved.join(', ')} cannot be passed as an argument`)
259  return args as Record<string, unknown>
260}
261
262/** One tool call: counted, scoped, and abortable. */
263async function invoke(st: RunState, scope: Scope, tool: string, args: unknown): Promise<CallOutcome> {
264  const { host, limits, signal } = st
265  if (!host || !limits) throw new Error('the program is not running')
266  if (signal?.aborted) throw new Error('interrupted')
267  if (!scope(tool)) throw new Error(`${tool} is not a tool this program may call`)
268  if (++st.calls > limits.maxCalls) throw new Error(`more than ${limits.maxCalls} tool calls in one run`)
269  return host.call(tool, toArgs(args, tool))
270}
271
272async function callOrThrow(st: RunState, scope: Scope, tool: string, args: unknown): Promise<unknown> {
273  const r = await invoke(st, scope, tool, args)
274  if (!r.ok) throw new Error(`${tool} failed: ${r.error}`)
275  return decode(r.text)
276}
277
278/** Runs `fn` over `items`, at most `n` at a time, keeping their order. */
279async function pool<T, R>(items: readonly T[], n: number, fn: (x: T) => Promise<R>, signal?: AbortSignal): Promise<R[]> {
280  const out = new Array<R>(items.length)
281  let next = 0
282  const worker = async () => {
283    while (next < items.length) {
284      if (signal?.aborted) throw new Error('interrupted')
285      const i = next++
286      out[i] = await fn(items[i]!)
287    }
288  }
289  await Promise.all(Array.from({ length: Math.max(1, Math.min(n, items.length)) }, worker))
290  return out
291}
292
293/** The base environment plus the host functions, bound to one compiled program's run state. */
294function programEnv(st: RunState, scope: Scope): Environment {
295  return baseEnv
296    .clone()
297    .registerFunction('call(string, map<string, dyn>): dyn', (tool: string, args: unknown) => callOrThrow(st, scope, tool, args))
298    .registerFunction('vars(): list<string>', () => [...st.store.values.keys()].sort())
299    .registerFunction('call(string): dyn', (tool: string) => callOrThrow(st, scope, tool, new Map()))
300    .registerFunction('tryCall(string, map<string, dyn>): map<string, dyn>', async (tool: string, args: unknown) => {
301      const r = await invoke(st, scope, tool, args)
302      return new Map<string, unknown>(r.ok ? [['ok', true], ['value', decode(r.text)], ['error', '']] : [['ok', false], ['value', null], ['error', r.error]])
303    })
304    .registerFunction('callEach(string, list): list<dyn>', async (tool: string, argsList: unknown[]) => {
305      // Every call's scope and the call budget are checked before the first one starts
306      if (!scope(tool)) throw new Error(`${tool} is not a tool this program may call`)
307      const limits = st.limits!
308      if (st.calls + argsList.length > limits.maxCalls)
309        throw new Error(`callEach would make ${argsList.length} calls; with ${st.calls} made, that is more than ${limits.maxCalls} in one run`)
310      return pool(argsList, limits.concurrency, (a) => callOrThrow(st, scope, tool, a), st.signal)
311    })
312}
313
314/** What a program's vars will be once it runs, checked against the cap before it does. */
315function checkVars(stmts: readonly Statement[], store: VarStore) {
316  const names = new Set(store.values.keys())
317  for (const s of stmts) {
318    if (s.kind !== 'var') continue
319    if (clears(s)) names.delete(s.name!)
320    else names.add(s.name!)
321    if (names.size > store.max)
322      throw new ProgramError(
323        store.max === 0
324          ? 'var is turned off (max_vars is 0); use let'
325          : `more than ${store.max} vars would be kept (kept now: ${[...store.values.keys()].join(', ') || 'none'}); clear one with \`var <name> = null\`, or use let`,
326        s.line,
327      )
328  }
329}
330
331/** The hint for an unknown name: the vars there are, when there are any. */
332function varsHint(message: string, store: VarStore): string {
333  if (!/Unknown variable/.test(message) || /\(/.test(message) || store.max === 0) return message
334  const kept = [...store.values.keys()]
335  return kept.length ? `${message} (vars kept from earlier programs: ${kept.sort().join(', ')})` : `${message} (no vars are kept: a \`let\` lasts one program, a \`var\` the session)`
336}
337
338/** Parses and type-checks a whole program, so a mistake costs no tool calls. `store`
339 *  holds the vars earlier programs kept; they are bound here as dyn. */
340export function compileProgram(text: string, scope: Scope, store: VarStore = { values: new Map(), max: 0 }): Compiled {
341  const stmts = parseProgram(text)
342  const run: RunState = { store, calls: 0 }
343  const env = programEnv(run, scope)
344  for (const name of store.values.keys()) env.registerVariable(name, 'dyn')
345  const statements: Compiled['statements'][number][] = []
346  const tools = new Set<string>()
347  const bound = new Set<string>() // names this program binds
348  for (const s of stmts) {
349    for (const m of s.source.matchAll(LITERAL_TOOL)) {
350      const tool = m[1] ?? m[2]!
351      if (!scope(tool)) throw new ProgramError(`${tool} is not a tool this program may call (see the tools tool)`, s.line)
352      tools.add(tool)
353    }
354    if (s.name !== undefined) {
355      const kept = store.values.has(s.name) && !bound.has(s.name)
356      // A var may replace one an earlier program kept; any other name is bound once
357      if (!IDENT.test(s.name) || (env.hasVariable(s.name) && !(kept && s.kind === 'var')))
358        throw new ProgramError(
359          kept ? `"${s.name}" is a var from an earlier program; use another name, or \`var ${s.name} = ...\` to replace it` : `"${s.name}" is already bound`,
360          s.line,
361        )
362    }
363    const source = desugar(s.source)
364    const res = env.check(source)
365    if (!res.valid) throw new ProgramError(varsHint(withHint(firstLine(res.error)), store), s.line)
366    let fn: (ctx: Record<string, unknown>) => unknown
367    try {
368      fn = env.parse(source) as unknown as typeof fn
369    } catch (e) {
370      throw new ProgramError(withHint(firstLine(e)), s.line)
371    }
372    if (s.name !== undefined) {
373      bound.add(s.name)
374      // A var is dyn, as it will be in later programs; a cleared one isn't bound at all
375      if (!env.hasVariable(s.name) && !clears(s)) env.registerVariable(s.name, s.kind === 'var' ? 'dyn' : String(res.type))
376    }
377    statements.push({ line: s.line, ...(s.name === undefined ? {} : { name: s.name, kind: s.kind! }), fn })
378  }
379  checkVars(stmts, store)
380  return { statements, tools: [...tools], run }
381}
382
383// ---- Running ----
384
385export type RunResult = { ok: true; output: string; calls: number } | { ok: false; error: string; calls: number }
386
387export interface RunOptions extends Limits {
388  maxOutput: number // characters of the rendered result
389  signal?: AbortSignal
390  /** The session's vars; without it a `var` is refused, as with a cap of 0. */
391  vars?: VarStore
392  /** Asked once the program compiles, before its first call, with the tools it names:
393   *  resolves to null to run it, or to why it may not run. */
394  approve?: (tools: readonly string[]) => Promise<string | null>
395}
396
397/** Cuts a result to `max` characters, saying how much was left out. */
398export function truncate(text: string, max: number): string {
399  if (text.length <= max) return text
400  return `${text.slice(0, max)}\n[truncated: ${text.length} characters in all; return less, e.g. with .take(n), .map() to fewer fields, or .truncate(n)]`
401}
402
403/** Compiles and runs a program. Every failure, whether compiling, a tool, a
404 *  limit or an interrupt, is a result, never a throw. */
405export async function runProgram(text: string, scope: Scope, host: Host, opts: RunOptions): Promise<RunResult> {
406  let compiled: Compiled
407  try {
408    compiled = compileProgram(text, scope, opts.vars)
409  } catch (e) {
410    return { ok: false, error: e instanceof ProgramError ? e.message : `the program does not compile: ${firstLine(e)}`, calls: 0 }
411  }
412  if (opts.approve) {
413    const refused = await opts.approve(compiled.tools)
414    if (refused !== null) return { ok: false, error: refused, calls: 0 }
415  }
416  const st = compiled.run
417  Object.assign(st, { host, limits: { maxCalls: opts.maxCalls, concurrency: opts.concurrency }, signal: opts.signal, calls: 0 })
418  const store = st.store
419  const vars: Record<string, unknown> = Object.fromEntries(store.values)
420  let value: unknown
421  for (const s of compiled.statements) {
422    try {
423      value = await s.fn(vars)
424    } catch (e) {
425      const where = s.name === undefined ? `line ${s.line}` : `line ${s.line} (${s.kind} ${s.name})`
426      return { ok: false, error: `${where}: ${withHint(firstLine(e))}`, calls: st.calls }
427    }
428    if (s.name === undefined) continue
429    if (s.kind === 'let') vars[s.name] = value
430    // A var is kept as soon as its line runs, so a later failure doesn't lose it; null clears it
431    else if (value === null) {
432      store.values.delete(s.name)
433      delete vars[s.name]
434    } else if (!store.values.has(s.name) && store.values.size >= store.max) {
435      return { ok: false, error: `line ${s.line} (var ${s.name}): more than ${store.max} vars would be kept; clear one with \`var <name> = null\``, calls: st.calls }
436    } else {
437      store.values.set(s.name, value)
438      vars[s.name] = value
439    }
440  }
441  return { ok: true, output: truncate(render(value), opts.maxOutput), calls: st.calls }
442}
443
hooks/spill.ts 44 lines
1// Claude Code keeps a tool result that is over a size limit out of the
2// conversation: it saves the output to a file and hands back a notice naming
3// the file. A program wants the data, so the host reads that file instead.
4// Pure and free of the mods API; register.ts does the read.
5
6// Two notices name the file: one for MCP output over its token limit (plain text
7// saved), one for any result over the size kept inline (the result's blocks as JSON)
8const NOTICES = [
9  /^(?:Error: )?result \([\d,]+ characters[^)]*\) exceeds maximum allowed tokens\. Output has been saved to (\/[^\n]+?)\.\n/,
10  /^<persisted-output>\nOutput too large \([^)\n]*\)\. Full output saved to: (\/[^\n]+)\n/,
11]
12
13/** The file a spill notice names, when `text` is one and the file is where Claude Code
14 *  keeps spilled results: this session's tool-results folder under
15 *  `<configDir>/projects/<project>/`. A tool can't point the host at any other file by
16 *  returning text that looks like a notice. */
17export function spilledFile(text: string, configDir: string, sessionId: string): string | undefined {
18  let path: string | undefined
19  for (const re of NOTICES) path ??= re.exec(text)?.[1]
20  if (!path) return undefined
21  const projects = `${configDir.replace(/\/+$/, '')}/projects/`
22  if (!path.startsWith(projects) || path.split('/').some((p) => p === '..' || p === '.')) return undefined
23  const rest = path.slice(projects.length).split('/')
24  // <project>/<session>/tool-results/<file>
25  if (rest.length !== 4 || rest[1] !== sessionId || rest[2] !== 'tool-results' || rest.some((p) => !p)) return undefined
26  return path
27}
28
29/** A spilled file's content as the tool's text: a saved list of content blocks is
30 *  joined back into the text the model would have read. */
31export function spilledText(path: string, content: string): string {
32  if (!path.endsWith('.json')) return content
33  let blocks: unknown
34  try {
35    blocks = JSON.parse(content)
36  } catch {
37    return content
38  }
39  const isText = (b: unknown): b is { type: 'text'; text: string } =>
40    !!b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' && typeof (b as { text?: unknown }).text === 'string'
41  if (!Array.isArray(blocks) || !blocks.length || !blocks.every((b) => !!b && typeof b === 'object' && 'type' in b)) return content
42  return blocks.filter(isText).map((b) => b.text).join('\n')
43}
44
hooks/stdlib.ts 197 lines
1// The CEL environment programs run in: data helpers, and the conversions between
2// JSON (what tools return and take) and CEL values. Pure and free of the mods API.
3// The host functions (call, tryCall, callEach) are added per run in program.ts.
4import { Environment } from './vendor/cel/cel.js'
5
6// ---- JSON <-> CEL ----
7
8const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Map)
9
10/** JSON data as CEL sees it: whole numbers become ints (bigint), the rest stays. */
11export function fromJson(v: unknown): unknown {
12  if (typeof v === 'number') return Number.isInteger(v) ? BigInt(v) : v
13  if (Array.isArray(v)) return v.map(fromJson)
14  if (isRecord(v)) {
15    // A null-prototype copy: no key of a tool's data can reach Object.prototype
16    const out: Record<string, unknown> = Object.create(null)
17    for (const [k, x] of Object.entries(v)) out[k] = fromJson(x)
18    return out
19  }
20  return v
21}
22
23/** A CEL value as JSON data: ints become numbers, maps become objects. */
24export function toJson(v: unknown): unknown {
25  if (typeof v === 'bigint') return Number(v)
26  if (Array.isArray(v)) return v.map(toJson)
27  if (v instanceof Map) return Object.fromEntries([...v].map(([k, x]) => [String(k), toJson(x)]))
28  if (v instanceof Uint8Array) return Array.from(v)
29  if (v && typeof v === 'object' && v.constructor?.name === 'UnsignedInt') return Number((v as { value: bigint }).value)
30  if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, toJson(x)]))
31  return v
32}
33
34/** A program's result as the model reads it: a string as it is, anything else as compact JSON. */
35export function render(v: unknown): string {
36  if (typeof v === 'string') return v
37  return JSON.stringify(toJson(v)) ?? 'null'
38}
39
40// ---- Helpers ----
41
42/** The value at a dotted path (`user.login`) of an element, or undefined. */
43function at(x: unknown, path: string): unknown {
44  for (const k of path.split('.')) {
45    if (x instanceof Map) x = x.get(k)
46    else if (x && typeof x === 'object' && !Array.isArray(x) && Object.hasOwn(x, k)) x = (x as Record<string, unknown>)[k]
47    else return undefined
48  }
49  return x
50}
51
52/** Total order for sort keys of one kind: numbers (int or double), strings, or bools. */
53function compare(a: unknown, b: unknown): number {
54  const num = (x: unknown) => typeof x === 'bigint' || typeof x === 'number'
55  if (num(a) && num(b)) return (a as number) < (b as number) ? -1 : (a as number) > (b as number) ? 1 : 0
56  if (typeof a === typeof b && (typeof a === 'string' || typeof a === 'boolean')) return a < (b as string) ? -1 : a > (b as string) ? 1 : 0
57  // Missing keys and nulls sort last
58  if (a === undefined || a === null) return b === undefined || b === null ? 0 : 1
59  if (b === undefined || b === null) return -1
60  throw new Error(`cannot compare ${typeName(a)} with ${typeName(b)}`)
61}
62
63function typeName(v: unknown): string {
64  if (typeof v === 'bigint') return 'int'
65  if (typeof v === 'number') return 'double'
66  if (v === null) return 'null'
67  if (Array.isArray(v)) return 'list'
68  if (v && typeof v === 'object') return 'map'
69  return typeof v
70}
71
72/** `-field` sorts descending. */
73function sortBy(l: unknown[], key: string): unknown[] {
74  const desc = key.startsWith('-')
75  const path = desc ? key.slice(1) : key
76  return l
77    .map((x, i) => [at(x, path), i, x] as const)
78    .sort((a, b) => (desc ? compare(b[0], a[0]) : compare(a[0], b[0])) || a[1] - b[1])
79    .map(([, , x]) => x)
80}
81
82/** Group keys are strings: an int or bool key is spelled as CEL prints it. */
83function groupKey(v: unknown): string {
84  if (typeof v === 'string') return v
85  if (v === undefined) return 'null'
86  if (typeof v === 'bigint' || typeof v === 'number' || typeof v === 'boolean' || v === null) return String(v)
87  return JSON.stringify(toJson(v))
88}
89
90function groupBy(l: unknown[], key: (x: unknown) => unknown): Map<string, unknown[]> {
91  const m = new Map<string, unknown[]>()
92  for (const x of l) {
93    const k = groupKey(key(x))
94    const g = m.get(k)
95    if (g) g.push(x)
96    else m.set(k, [x])
97  }
98  return m
99}
100
101/** Counts, most frequent first (ties keep first-seen order). */
102function countBy(l: unknown[], key: (x: unknown) => unknown): Map<string, bigint> {
103  const groups = [...groupBy(l, key)].sort((a, b) => b[1].length - a[1].length)
104  return new Map(groups.map(([k, g]) => [k, BigInt(g.length)]))
105}
106
107function numbers(l: unknown[], what: string): (bigint | number)[] {
108  for (const x of l) if (typeof x !== 'bigint' && typeof x !== 'number') throw new Error(`${what}: not a number: ${typeName(x)}`)
109  return l as (bigint | number)[]
110}
111
112function sum(l: unknown[]): bigint | number {
113  const xs = numbers(l, 'sum')
114  if (xs.every((x) => typeof x === 'bigint')) return (xs as bigint[]).reduce((a, b) => a + b, 0n)
115  return xs.reduce<number>((a, b) => a + Number(b), 0)
116}
117
118function extreme(l: unknown[], what: string, sign: 1 | -1): unknown {
119  if (!l.length) throw new Error(`${what}: empty list`)
120  return l.reduce((a, b) => (compare(b, a) * sign > 0 ? b : a))
121}
122
123function regex(re: string): RegExp {
124  try {
125    return new RegExp(re, 'g')
126  } catch (e) {
127    throw new Error(`invalid regex: ${(e as Error).message}`)
128  }
129}
130
131const jsonParse = (s: string) => {
132  try {
133    return fromJson(JSON.parse(s))
134  } catch (e) {
135    throw new Error(`json: ${(e as Error).message}`)
136  }
137}
138
139// ---- The environment ----
140
141/** Structural limits on a program's statements: generous for data work, tight against abuse. */
142export const LIMITS = { maxAstNodes: 5000, maxDepth: 64, maxListElements: 1000, maxMapEntries: 1000, maxCallArguments: 16 }
143
144export const baseEnv = new Environment({ homogeneousAggregateLiterals: false, enableOptionalTypes: true, limits: LIMITS })
145  .registerFunction('json(string): dyn', jsonParse)
146  .registerFunction('toJson(dyn): string', (v: unknown) => JSON.stringify(toJson(v)) ?? 'null')
147  .registerFunction('map.keys(): list<dyn>', (m: unknown) => (m instanceof Map ? [...m.keys()] : Object.keys(m as object)))
148  .registerFunction('map.values(): list<dyn>', (m: unknown) => (m instanceof Map ? [...m.values()] : Object.values(m as object)))
149  .registerFunction('keys(map): list<dyn>', (m: unknown) => (m instanceof Map ? [...m.keys()] : Object.keys(m as object)))
150  .registerFunction('values(map): list<dyn>', (m: unknown) => (m instanceof Map ? [...m.values()] : Object.values(m as object)))
151  .registerFunction('map.take(int): map<string, dyn>', (m: unknown, n: bigint) =>
152    new Map((m instanceof Map ? [...m] : Object.entries(m as object)).slice(0, Math.max(0, Number(n)))),
153  )
154  .registerFunction('list.take(int): list<dyn>', (l: unknown[], n: bigint) => l.slice(0, Math.max(0, Number(n))))
155  .registerFunction('list.drop(int): list<dyn>', (l: unknown[], n: bigint) => l.slice(Math.max(0, Number(n))))
156  .registerFunction('list.reverse(): list<dyn>', (l: unknown[]) => [...l].reverse())
157  .registerFunction('list.sort(): list<dyn>', (l: unknown[]) => [...l].sort(compare))
158  .registerFunction('list.sortBy(string): list<dyn>', sortBy)
159  // The targets of the lambda forms (program.ts desugar): lists of [key, element] pairs
160  .registerFunction('list.sortPairs_(): list<dyn>', (l: [unknown, unknown][]) =>
161    l
162      .map((p, i) => [p, i] as const)
163      .sort((a, b) => compare(a[0][0], b[0][0]) || a[1] - b[1])
164      .map(([p]) => p[1]),
165  )
166  .registerFunction('list.groupPairs_(): map<string, list<dyn>>', (l: [unknown, unknown][]) => {
167    const m = new Map<string, unknown[]>()
168    for (const [k, x] of l) {
169      const g = m.get(groupKey(k))
170      if (g) g.push(x)
171      else m.set(groupKey(k), [x])
172    }
173    return m
174  })
175  .registerFunction('list.distinct(): list<dyn>', (l: unknown[]) => {
176    const seen = new Set<string>()
177    return l.filter((x) => {
178      const k = typeof x === 'string' ? `s${x}` : `j${JSON.stringify(toJson(x))}`
179      return !seen.has(k) && !!seen.add(k)
180    })
181  })
182  .registerFunction('list.flatten(): list<dyn>', (l: unknown[]) => l.flat())
183  .registerFunction('list.groupBy(string): map<string, list<dyn>>', (l: unknown[], k: string) => groupBy(l, (x) => at(x, k)))
184  .registerFunction('list.countBy(string): map<string, int>', (l: unknown[], k: string) => countBy(l, (x) => at(x, k)))
185  .registerFunction('list.countBy(): map<string, int>', (l: unknown[]) => countBy(l, (x) => x))
186  .registerFunction('list.sum(): dyn', sum)
187  .registerFunction('list.min(): dyn', (l: unknown[]) => extreme(l, 'min', -1))
188  .registerFunction('list.max(): dyn', (l: unknown[]) => extreme(l, 'max', 1))
189  .registerFunction('string.replace(string, string): string', (s: string, a: string, b: string) => s.split(a).join(b))
190  .registerFunction('string.find(string): string', (s: string, re: string) => s.match(regex(re))?.[0] ?? '')
191  .registerFunction('string.findAll(string): list<string>', (s: string, re: string) => s.match(regex(re)) ?? [])
192  .registerFunction('string.lines(): list<string>', (s: string) => s.split(/\r?\n/))
193  .registerFunction('string.truncate(int): string', (s: string, n: bigint) => {
194    const max = Math.max(0, Number(n))
195    return s.length > max ? `${s.slice(0, max)}…` : s
196  })
197
hooks/vendor/cel/cel.js 4552 lines
1// design/v0.2-custom-rules/node_modules/@marcbachmann/cel-js/lib/errors.js
2class CelError extends Error {
3  #node;
4  #code;
5  #range;
6  #summary;
7  constructor({ name, code, message, node, cause, range }) {
8    super(message, cause ? { cause } : undefined);
9    this.name = name;
10    this.#code = code;
11    this.#summary = message;
12    this.#node = node;
13    this.#range = range && normalizeRange(range) || normalizeRange(node);
14    if (!node?.input)
15      return;
16    this.message = formatErrorWithHighlight(this.#summary, node, this.#range);
17  }
18  get node() {
19    return this.#node;
20  }
21  get code() {
22    return this.#code;
23  }
24  get range() {
25    return this.#range;
26  }
27  get summary() {
28    return this.#summary;
29  }
30  withAst(node) {
31    if (this.#node || !node?.input)
32      return this;
33    this.#node = node;
34    this.#range ??= normalizeRange(node);
35    this.message = formatErrorWithHighlight(this.#summary, node, this.#range);
36    return this;
37  }
38}
39function normalizeArgs(name, defaultCode, message, node, cause) {
40  if (typeof message === "string")
41    return { name, code: defaultCode, message, node, cause };
42  const opts = message;
43  if (typeof opts !== "object")
44    throw new Error("First param to error must be a string or object");
45  return {
46    name,
47    code: opts.code || defaultCode,
48    message: opts.message,
49    node: opts.node,
50    cause: opts.cause,
51    range: opts.range
52  };
53}
54
55class ParseError extends CelError {
56  constructor(message, node, cause) {
57    super(normalizeArgs("ParseError", "parse_error", message, node, cause));
58  }
59}
60
61class EvaluationError extends CelError {
62  constructor(message, node, cause) {
63    super(normalizeArgs("EvaluationError", "evaluation_error", message, node, cause));
64  }
65}
66
67class TypeError2 extends CelError {
68  constructor(message, node, cause) {
69    super(normalizeArgs("TypeError", "type_error", message, node, cause));
70  }
71}
72function parseError(code, message, node) {
73  if (typeof code === "object")
74    return new ParseError(code);
75  return new ParseError({ code, message, node });
76}
77function evaluationError(code, message, node) {
78  if (typeof code === "object")
79    return new EvaluationError(code);
80  return new EvaluationError({ code, message, node });
81}
82function typeError(code, message, node) {
83  if (typeof code === "object")
84    return new TypeError2(code);
85  return new TypeError2({ code, message, node });
86}
87function normalizeRange(node) {
88  const start = node?.pos ?? node?.start;
89  if (typeof start !== "number")
90    return;
91  const end = typeof node.end === "number" ? node.end : start;
92  return { start, end };
93}
94function formatErrorWithHighlight(message, node, range) {
95  const pos = node.pos ?? range?.start;
96  if (typeof pos !== "number")
97    return message;
98  const input = node.input;
99  let lineNum = 1;
100  let currentPos = 0;
101  let columnNum = 0;
102  while (currentPos < pos) {
103    if (input[currentPos] === `
104`) {
105      lineNum++;
106      columnNum = 0;
107    } else {
108      columnNum++;
109    }
110    currentPos++;
111  }
112  let contextStart = pos;
113  let contextEnd = pos;
114  while (contextStart > 0 && input[contextStart - 1] !== `
115`)
116    contextStart--;
117  while (contextEnd < input.length && input[contextEnd] !== `
118`)
119    contextEnd++;
120  const line = input.slice(contextStart, contextEnd);
121  const highlight = `> ${`${lineNum}`.padStart(4, " ")} | ${line}
122${" ".repeat(9 + columnNum)}^`;
123  return `${message}
124
125${highlight}`;
126}
127function attachErrorAst(error, node) {
128  if (error instanceof CelError)
129    return error.withAst(node);
130  return error;
131}
132
133// design/v0.2-custom-rules/node_modules/@marcbachmann/cel-js/lib/optional.js
134class Optional {
135  #value;
136  constructor(value) {
137    this.#value = value;
138  }
139  static of(value) {
140    if (value === undefined)
141      return OPTIONAL_NONE;
142    return new Optional(value);
143  }
144  static none() {
145    return OPTIONAL_NONE;
146  }
147  hasValue() {
148    return this.#value !== undefined;
149  }
150  value() {
151    if (this.#value === undefined) {
152      throw evaluationError("optional_value_missing", "Optional value is not present");
153    }
154    return this.#value;
155  }
156  or(optional) {
157    if (this.#value !== undefined)
158      return this;
159    if (optional instanceof Optional)
160      return optional;
161    throw evaluationError("invalid_optional_argument", "Optional.or must be called with an Optional argument");
162  }
163  orValue(defaultValue) {
164    return this.#value === undefined ? defaultValue : this.#value;
165  }
166  get [Symbol.toStringTag]() {
167    return "optional";
168  }
169  [Symbol.for("nodejs.util.inspect.custom")]() {
170    return this.#value === undefined ? `Optional { none }` : `Optional { value: ${JSON.stringify(this.#value)} }`;
171  }
172}
173var OPTIONAL_NONE = Object.freeze(new Optional);
174
175class OptionalNamespace {
176}
177var optionalNamespace = new OptionalNamespace;
178function toggleOptionalTypes(registry, enable) {
179  const optionalConstant = enable ? optionalNamespace : undefined;
180  registry.deleteVariable("optional");
181  registry.registerConstant("optional", "OptionalNamespace", optionalConstant);
182}
183function register(registry) {
184  const sync = { async: false };
185  const functionOverload = (sig, handler) => registry.registerFunctionOverload(sig, handler, sync);
186  const optionalConstant = registry.enableOptionalTypes ? optionalNamespace : undefined;
187  registry.registerType("OptionalNamespace", OptionalNamespace);
188  registry.registerConstant("optional", "OptionalNamespace", optionalConstant);
189  functionOverload("optional.hasValue(): bool", (v) => v.hasValue());
190  functionOverload("optional<A>.value(): A", (v) => v.value());
191  registry.registerFunctionOverload("OptionalNamespace.none(): optional<T>", () => Optional.none());
192  functionOverload("OptionalNamespace.of(A): optional<A>", (_, value) => Optional.of(value));
193  function ensureOptional(value, ast, description) {
194    if (value instanceof Optional)
195      return value;
196    throw evaluationError("optional_expected", `${description} must be optional`, ast);
197  }
198  function evaluateOptional(ev, macro, ctx) {
199    const v = ev.run(macro.receiver, ctx);
200    if (v instanceof Promise)
201      return v.then((_v) => handleOptionalResolved(_v, ev, macro, ctx));
202    return handleOptionalResolved(v, ev, macro, ctx);
203  }
204  function handleOptionalResolved(value, ev, macro, ctx) {
205    const optional = ensureOptional(value, macro.receiver, `${macro.functionDesc} receiver`);
206    if (optional.hasValue())
207      return macro.onHasValue(optional);
208    return macro.onEmpty(ev, macro, ctx);
209  }
210  function ensureOptionalType(checker, node, ctx, description) {
211    const type = checker.check(node, ctx);
212    if (type.kind === "optional")
213      return type;
214    if (type.kind === "dyn")
215      return checker.getType("optional");
216    throw checker.createError("optional_expected", `${description} must be optional, got '${type}'`, node);
217  }
218  function createOptionalMacro({ functionDesc, evaluate, typeCheck, onHasValue, onEmpty }) {
219    return ({ ast, args, receiver }) => ({
220      ast,
221      functionDesc,
222      receiver,
223      arg: args[0],
224      evaluate,
225      typeCheck,
226      onHasValue,
227      onEmpty
228    });
229  }
230  const invalidOrValueReceiver = "optional.orValue() receiver";
231  const invalidOrReceiver = "optional.or(optional) receiver";
232  const invalidOrArg = "optional.or(optional) argument";
233  registry.registerFunctionOverload("optional.or(ast): optional<dyn>", createOptionalMacro({
234    functionDesc: "optional.or(optional)",
235    evaluate: evaluateOptional,
236    typeCheck(check, macro, ctx) {
237      const l = ensureOptionalType(check, macro.receiver, ctx, invalidOrReceiver);
238      const r = ensureOptionalType(check, macro.arg, ctx, invalidOrArg);
239      if (!(macro.receiver.maybeAsync || macro.arg.maybeAsync))
240        macro.ast.setMeta("async", false);
241      const unified = l.unify(check.registry, r);
242      if (unified)
243        return unified;
244      throw check.createError("incompatible_argument_type", `${macro.functionDesc} argument must be compatible type, got '${l}' and '${r}'`, macro.arg);
245    },
246    onHasValue: (optional) => optional,
247    onEmpty(ev, macro, ctx) {
248      const ast = macro.arg;
249      const v = ev.run(ast, ctx);
250      if (v instanceof Promise)
251        return v.then((_v) => ensureOptional(_v, ast, invalidOrArg));
252      return ensureOptional(v, ast, invalidOrArg);
253    }
254  }));
255  registry.registerFunctionOverload("optional.orValue(ast): dyn", createOptionalMacro({
256    functionDesc: "optional.orValue(value)",
257    onHasValue: (optionalValue) => optionalValue.value(),
258    onEmpty(ev, macro, ctx) {
259      return ev.run(macro.arg, ctx);
260    },
261    evaluate: evaluateOptional,
262    typeCheck(check, macro, ctx) {
263      const l = ensureOptionalType(check, macro.receiver, ctx, invalidOrValueReceiver).valueType;
264      const r = check.check(macro.arg, ctx);
265      if (!(macro.receiver.maybeAsync || macro.arg.maybeAsync))
266        macro.ast.setMeta("async", false);
267      const unified = l.unify(check.registry, r);
268      if (unified)
269        return unified;
270      throw check.createError("incompatible_argument_type", `${macro.functionDesc} argument must be compatible type, got '${l}' and '${r}'`, macro.arg);
271    }
272  }));
273}
274
275// design/v0.2-custom-rules/node_modules/@marcbachmann/cel-js/lib/globals.js
276var hasOwn = Object.hasOwn;
277var objKeys = Object.keys;
278var objFreeze = Object.freeze;
279var objEntries = Object.entries;
280var isArray = Array.isArray;
281var arrayFrom = Array.from;
282var MIN_UINT = 0n;
283var MAX_UINT = 18446744073709551615n;
284var MAX_INT = 9223372036854775807n;
285var MIN_INT = -9223372036854775808n;
286function isAsync(fn, fallback) {
287  if (fn?.[Symbol.toStringTag] === "AsyncFunction")
288    return true;
289  return typeof fallback === "boolean" ? fallback : true;
290}
291var RESERVED = new Set([
292  "as",
293  "break",
294  "const",
295  "continue",
296  "else",
297  "for",
298  "function",
299  "if",
300  "import",
301  "let",
302  "loop",
303  "package",
304  "namespace",
305  "return",
306  "var",
307  "void",
308  "while",
309  "__proto__",
310  "prototype"
311]);
312
313// design/v0.2-custom-rules/node_modules/@marcbachmann/cel-js/lib/functions.js
314class UnsignedInt {
315  #value;
316  constructor(value) {
317    this.verify(typeof value === "bigint" ? value : BigInt(value));
318  }
319  get value() {
320    return this.#value;
321  }
322  valueOf() {
323    return this.#value;
324  }
325  toString() {
326    return `${this.#value}`;
327  }
328  verify(v) {
329    if (v < MIN_UINT || v > MAX_UINT) {
330      throw evaluationError("numeric_overflow", "Unsigned integer overflow");
331    }
332    this.#value = v;
333  }
334  get [Symbol.toStringTag]() {
335    return `value = ${this.#value}`;
336  }
337  [Symbol.for("nodejs.util.inspect.custom")]() {
338    return `UnsignedInteger { value: ${this.#value} }`;
339  }
340}
341var billion = 1e9;
342var billionBigInt = 1000000000n;
343var UNIT_NANOSECONDS = {
344  h: 3600000000000n,
345  m: 60000000000n,
346  s: billionBigInt,
347  ms: 1000000n,
348  us: 1000n,
349  µs: 1000n,
350  ns: 1n
351};
352
353class Duration {
354  #seconds;
355  #nanos;
356  constructor(seconds, nanos = 0) {
357    this.#seconds = BigInt(seconds);
358    this.#nanos = nanos;
359  }
360  get seconds() {
361    return this.#seconds;
362  }
363  get nanos() {
364    return this.#nanos;
365  }
366  valueOf() {
367    return Number(this.#seconds) * 1000 + this.#nanos / 1e6;
368  }
369  static fromMilliseconds(ms) {
370    const totalNanos = BigInt(Math.trunc(ms * 1e6));
371    const seconds = totalNanos / billionBigInt;
372    const nanos = Number(totalNanos % billionBigInt);
373    return new Duration(seconds, nanos);
374  }
375  addDuration(other) {
376    const nanos = this.#nanos + other.nanos;
377    return new Duration(this.#seconds + other.seconds + BigInt(Math.floor(nanos / billion)), nanos % billion);
378  }
379  subtractDuration(other) {
380    const nanos = this.#nanos - other.nanos;
381    return new Duration(this.#seconds - other.seconds + BigInt(Math.floor(nanos / billion)), (nanos + billion) % billion);
382  }
383  extendTimestamp(ts) {
384    return new Date(ts.getTime() + Number(this.#seconds) * 1000 + Math.floor(this.#nanos / 1e6));
385  }
386  subtractTimestamp(ts) {
387    return new Date(ts.getTime() - Number(this.#seconds) * 1000 - Math.floor(this.#nanos / 1e6));
388  }
389  toString() {
390    const nanos = this.#nanos ? (this.#nanos / billion).toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 9 }).slice(1) : "";
391    return `${this.#seconds}${nanos}s`;
392  }
393  getHours() {
394    return this.#seconds / 3600n;
395  }
396  getMinutes() {
397    return this.#seconds / 60n;
398  }
399  getSeconds() {
400    return this.#seconds;
401  }
402  getMilliseconds() {
403    return this.#seconds * 1000n + BigInt(Math.floor(this.#nanos / 1e6));
404  }
405  get [Symbol.toStringTag]() {
406    return "google.protobuf.Duration";
407  }
408  [Symbol.for("nodejs.util.inspect.custom")]() {
409    return `google.protobuf.Duration { seconds: ${this.#seconds}, nanos: ${this.#nanos} }`;
410  }
411}
412function registerFunctions(registry) {
413  const sync = { async: false };
414  const functionOverload = (sig, handler) => registry.registerFunctionOverload(sig, handler, sync);
415  const identity = (v) => v;
416  functionOverload("dyn(dyn): dyn", identity);
417  for (const _t in TYPES) {
418    const type = TYPES[_t];
419    if (!(type instanceof Type))
420      continue;
421    functionOverload(`type(${type.name}): type`, () => type);
422  }
423  functionOverload("bool(bool): bool", identity);
424  functionOverload("bool(string): bool", (v) => {
425    switch (v) {
426      case "1":
427      case "t":
428      case "true":
429      case "TRUE":
430      case "True":
431        return true;
432      case "0":
433      case "f":
434      case "false":
435      case "FALSE":
436      case "False":
437        return false;
438      default:
439        throw evaluationError("bool_conversion_error", `bool() conversion error: invalid string value "${v}"`);
440    }
441  });
442  functionOverload("size(string): int", (v) => BigInt(stringSize(v)));
443  functionOverload("size(bytes): int", (v) => BigInt(v.length));
444  functionOverload("size(list): int", (v) => BigInt(v.length ?? v.size));
445  functionOverload("size(map): int", (v) => BigInt(v instanceof Map ? v.size : objKeys(v).length));
446  functionOverload("string.size(): int", (v) => BigInt(stringSize(v)));
447  functionOverload("bytes.size(): int", (v) => BigInt(v.length));
448  functionOverload("list.size(): int", (v) => BigInt(v.length ?? v.size));
449  functionOverload("map.size(): int", (v) => BigInt(v instanceof Map ? v.size : objKeys(v).length));
450  functionOverload("bytes(string): bytes", (v) => ByteOpts.fromString(v));
451  functionOverload("bytes(bytes): bytes", identity);
452  functionOverload("double(double): double", identity);
453  functionOverload("double(int): double", (v) => Number(v));
454  functionOverload("double(uint): double", (v) => Number(v));
455  functionOverload("double(string): double", (v) => {
456    if (!v || v !== v.trim())
457      throw evaluationError("double_conversion_error", "double() type error: cannot convert to double");
458    const s = v.toLowerCase();
459    switch (s) {
460      case "inf":
461      case "+inf":
462      case "infinity":
463      case "+infinity":
464        return Number.POSITIVE_INFINITY;
465      case "-inf":
466      case "-infinity":
467        return Number.NEGATIVE_INFINITY;
468      case "nan":
469        return Number.NaN;
470      default: {
471        const parsed = Number(v);
472        if (!Number.isNaN(parsed))
473          return parsed;
474        throw evaluationError("double_conversion_error", "double() type error: cannot convert to double");
475      }
476    }
477  });
478  functionOverload("int(int): int", identity);
479  functionOverload("int(double): int", (v) => {
480    if (Number.isFinite(v))
481      return BigInt(Math.trunc(v));
482    throw evaluationError("numeric_overflow", "int() type error: integer overflow");
483  });
484  functionOverload("int(string): int", (v) => {
485    if (v !== v.trim() || v.length > 20 || v.includes("0x")) {
486      throw evaluationError("int_conversion_error", "int() type error: cannot convert to int");
487    }
488    try {
489      const num = BigInt(v);
490      if (num <= MAX_INT && num >= MIN_INT)
491        return num;
492    } catch (_e) {}
493    throw evaluationError("int_conversion_error", "int() type error: cannot convert to int");
494  });
495  functionOverload("uint(uint): uint", identity);
496  functionOverload("uint(int): uint", (v) => {
497    try {
498      return new UnsignedInt(v);
499    } catch (e) {
500      throw evaluationError("uint_conversion_error", "uint() type error: cannot convert to uint");
501    }
502  });
503  functionOverload("uint(double): uint", (v) => {
504    try {
505      return new UnsignedInt(Math.trunc(v));
506    } catch (e) {
507      throw evaluationError("numeric_overflow", "uint() type error: unsigned integer overflow");
508    }
509  });
510  functionOverload("uint(string): uint", (v) => {
511    if (v !== v.trim() || v.length > 20 || v.includes("0x")) {
512      throw evaluationError("uint_conversion_error", "uint() type error: cannot convert to uint");
513    }
514    try {
515      return new UnsignedInt(v);
516    } catch (e) {
517      throw evaluationError("uint_conversion_error", "uint() type error: cannot convert to uint");
518    }
519  });
520  functionOverload("string(string): string", identity);
521  functionOverload("string(bool): string", (v) => `${v}`);
522  functionOverload("string(int): string", (v) => `${v}`);
523  functionOverload("string(uint): string", (v) => `${v}`);
524  functionOverload("string(bytes): string", (v) => ByteOpts.toUtf8(v));
525  functionOverload("string(double): string", (v) => {
526    if (v === Infinity)
527      return "+Inf";
528    if (v === -Infinity)
529      return "-Inf";
530    return `${v}`;
531  });
532  functionOverload("string.startsWith(string): bool", (a, b) => a.startsWith(b));
533  functionOverload("string.endsWith(string): bool", (a, b) => a.endsWith(b));
534  functionOverload("string.contains(string): bool", (a, b) => a.includes(b));
535  functionOverload("string.lowerAscii(): string", (a) => a.toLowerCase());
536  functionOverload("string.upperAscii(): string", (a) => a.toUpperCase());
537  functionOverload("string.trim(): string", (a) => a.trim());
538  functionOverload("string.indexOf(string): int", (string, search) => BigInt(string.indexOf(search)));
539  functionOverload("string.indexOf(string, int): int", (string, search, fromIndex) => {
540    if (search === "")
541      return fromIndex;
542    fromIndex = Number(fromIndex);
543    if (fromIndex < 0 || fromIndex >= string.length) {
544      throw evaluationError("index_out_of_range", "string.indexOf(search, fromIndex): fromIndex out of range");
545    }
546    return BigInt(string.indexOf(search, fromIndex));
547  });
548  functionOverload("string.lastIndexOf(string): int", (string, search) => BigInt(string.lastIndexOf(search)));
549  functionOverload("string.lastIndexOf(string, int): int", (string, search, fromIndex) => {
550    if (search === "")
551      return fromIndex;
552    fromIndex = Number(fromIndex);
553    if (fromIndex < 0 || fromIndex >= string.length) {
554      throw evaluationError("index_out_of_range", "string.lastIndexOf(search, fromIndex): fromIndex out of range");
555    }
556    return BigInt(string.lastIndexOf(search, fromIndex));
557  });
558  functionOverload("string.substring(int): string", (string, start) => {
559    start = Number(start);
560    if (start < 0 || start > string.length) {
561      throw evaluationError("index_out_of_range", "string.substring(start, end): start index out of range");
562    }
563    return string.substring(start);
564  });
565  functionOverload("string.substring(int, int): string", (string, start, end) => {
566    start = Number(start);
567    if (start < 0 || start > string.length) {
568      throw evaluationError("index_out_of_range", "string.substring(start, end): start index out of range");
569    }
570    end = Number(end);
571    if (end < start || end > string.length) {
572      throw evaluationError("index_out_of_range", "string.substring(start, end): end index out of range");
573    }
574    return string.substring(start, end);
575  });
576  functionOverload("string.matches(string): bool", (a, b) => {
577    try {
578      return new RegExp(b).test(a);
579    } catch (_err) {
580      throw evaluationError("invalid_regular_expression", `Invalid regular expression: ${b}`);
581    }
582  });
583  functionOverload("string.split(string): list<string>", (s, sep) => s.split(sep));
584  functionOverload("string.split(string, int): list<string>", (s, sep, l) => {
585    l = Number(l);
586    if (l === 0)
587      return [];
588    const parts = s.split(sep);
589    if (l < 0 || parts.length <= l)
590      return parts;
591    const limited = parts.slice(0, l - 1);
592    limited.push(parts.slice(l - 1).join(sep));
593    return limited;
594  });
595  functionOverload("list<string>.join(): string", (v) => {
596    for (let i = 0;i < v.length; i++) {
597      if (typeof v[i] !== "string") {
598        throw evaluationError("invalid_list_element_type", "string.join(): list must contain only strings");
599      }
600    }
601    return v.join("");
602  });
603  functionOverload("list<string>.join(string): string", (v, sep) => {
604    for (let i = 0;i < v.length; i++) {
605      if (typeof v[i] !== "string") {
606        throw evaluationError("invalid_list_element_type", "string.join(separator): list must contain only strings");
607      }
608    }
609    return v.join(sep);
610  });
611  const textEncoder = new TextEncoder("utf8");
612  const textDecoder = new TextDecoder("utf8");
613  const ByteOpts = typeof Buffer !== "undefined" ? {
614    byteLength: (v) => Buffer.byteLength(v),
615    fromString: (str) => Buffer.from(str, "utf8"),
616    toHex: (b) => Buffer.prototype.hexSlice.call(b, 0, b.length),
617    toBase64: (b) => Buffer.prototype.base64Slice.call(b, 0, b.length),
618    toUtf8: (b) => Buffer.prototype.utf8Slice.call(b, 0, b.length),
619    jsonParse: (b) => JSON.parse(b)
620  } : {
621    textEncoder: new TextEncoder("utf8"),
622    byteLength: (v) => textEncoder.encode(v).length,
623    fromString: (str) => textEncoder.encode(str),
624    toHex: Uint8Array.prototype.toHex ? (b) => b.toHex() : (b) => arrayFrom(b, (i) => i.toString(16).padStart(2, "0")).join(""),
625    toBase64: Uint8Array.prototype.toBase64 ? (b) => b.toBase64() : (b) => btoa(arrayFrom(b, (i) => String.fromCodePoint(i)).join("")),
626    toUtf8: (b) => textDecoder.decode(b),
627    jsonParse: (b) => JSON.parse(textEncoder.decode(b))
628  };
629  functionOverload("bytes.json(): map", ByteOpts.jsonParse);
630  functionOverload("bytes.hex(): string", ByteOpts.toHex);
631  functionOverload("bytes.string(): string", ByteOpts.toUtf8);
632  functionOverload("bytes.base64(): string", ByteOpts.toBase64);
633  functionOverload("bytes.at(int): int", (b, index) => {
634    if (index < 0 || index >= b.length) {
635      throw evaluationError("index_out_of_range", "Bytes index out of range");
636    }
637    return BigInt(b[index]);
638  });
639  const TS = "google.protobuf.Timestamp";
640  const GPD = "google.protobuf.Duration";
641  const TimestampType = registry.registerType(TS, Date).typeType;
642  const DurationType = registry.registerType(GPD, Duration).typeType;
643  registry.registerConstant("google", "map<string, map<string, type>>", {
644    protobuf: { Duration: DurationType, Timestamp: TimestampType }
645  });
646  function tzDate(d, timeZone) {
647    return new Date(d.toLocaleString("en-US", { timeZone }));
648  }
649  function getDayOfYear(d, tz) {
650    const workingDate = tz ? tzDate(d, tz) : new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
651    const start = new Date(workingDate.getFullYear(), 0, 0);
652    return BigInt(Math.floor((workingDate - start) / 86400000) - 1);
653  }
654  functionOverload(`timestamp(string): ${TS}`, (v) => {
655    if (v.length < 20 || v.length > 30) {
656      throw evaluationError("invalid_timestamp", "timestamp() requires a string in ISO 8601 format");
657    }
658    const d = new Date(v);
659    if (d <= 253402300799999 && d >= -62135596800000)
660      return d;
661    throw evaluationError("invalid_timestamp", "timestamp() requires a string in ISO 8601 format");
662  });
663  functionOverload(`timestamp(int): ${TS}`, (i) => {
664    i = Number(i) * 1000;
665    if (i <= 253402300799999 && i >= -62135596800000)
666      return new Date(i);
667    throw evaluationError("invalid_timestamp", "timestamp() requires a valid integer unix timestamp");
668  });
669  functionOverload(`${TS}.getDate(): int`, (d) => BigInt(d.getUTCDate()));
670  functionOverload(`${TS}.getDate(string): int`, (d, tz) => BigInt(tzDate(d, tz).getDate()));
671  functionOverload(`${TS}.getDayOfMonth(): int`, (d) => BigInt(d.getUTCDate() - 1));
672  functionOverload(`${TS}.getDayOfMonth(string): int`, (d, tz) => BigInt(tzDate(d, tz).getDate() - 1));
673  functionOverload(`${TS}.getDayOfWeek(): int`, (d) => BigInt(d.getUTCDay()));
674  functionOverload(`${TS}.getDayOfWeek(string): int`, (d, tz) => BigInt(tzDate(d, tz).getDay()));
675  functionOverload(`${TS}.getDayOfYear(): int`, getDayOfYear);
676  functionOverload(`${TS}.getDayOfYear(string): int`, getDayOfYear);
677  functionOverload(`${TS}.getFullYear(): int`, (d) => BigInt(d.getUTCFullYear()));
678  functionOverload(`${TS}.getFullYear(string): int`, (d, tz) => BigInt(tzDate(d, tz).getFullYear()));
679  functionOverload(`${TS}.getHours(): int`, (d) => BigInt(d.getUTCHours()));
680  functionOverload(`${TS}.getHours(string): int`, (d, tz) => BigInt(tzDate(d, tz).getHours()));
681  functionOverload(`${TS}.getMilliseconds(): int`, (d) => BigInt(d.getUTCMilliseconds()));
682  functionOverload(`${TS}.getMilliseconds(string): int`, (d) => BigInt(d.getUTCMilliseconds()));
683  functionOverload(`${TS}.getMinutes(): int`, (d) => BigInt(d.getUTCMinutes()));
684  functionOverload(`${TS}.getMinutes(string): int`, (d, tz) => BigInt(tzDate(d, tz).getMinutes()));
685  functionOverload(`${TS}.getMonth(): int`, (d) => BigInt(d.getUTCMonth()));
686  functionOverload(`${TS}.getMonth(string): int`, (d, tz) => BigInt(tzDate(d, tz).getMonth()));
687  functionOverload(`${TS}.getSeconds(): int`, (d) => BigInt(d.getUTCSeconds()));
688  functionOverload(`${TS}.getSeconds(string): int`, (d, tz) => BigInt(tzDate(d, tz).getSeconds()));
689  const parseDurationPattern = /(\d*\.?\d*)(ns|us|µs|ms|s|m|h)/;
690  function parseDuration(string) {
691    if (!string)
692      throw evaluationError("invalid_duration", `Invalid duration string: ''`);
693    const isNegative = string[0] === "-";
694    if (string[0] === "-" || string[0] === "+")
695      string = string.slice(1);
696    let nanoseconds = BigInt(0);
697    while (true) {
698      const match = parseDurationPattern.exec(string);
699      if (!match)
700        throw evaluationError("invalid_duration", `Invalid duration string: ${string}`);
701      if (match.index !== 0)
702        throw evaluationError("invalid_duration", `Invalid duration string: ${string}`);
703      string = string.slice(match[0].length);
704      const unitNanos = UNIT_NANOSECONDS[match[2]];
705      const [intPart = "0", fracPart = ""] = match[1].split(".");
706      const intVal = BigInt(intPart) * unitNanos;
707      const fracNanos = fracPart ? BigInt(fracPart.slice(0, 13).padEnd(13, "0")) * unitNanos / 10000000000000n : 0n;
708      nanoseconds += intVal + fracNanos;
709      if (string === "")
710        break;
711    }
712    const seconds = nanoseconds >= billionBigInt ? nanoseconds / billionBigInt : 0n;
713    const nanos = Number(nanoseconds % billionBigInt);
714    if (isNegative)
715      return new Duration(-seconds, -nanos);
716    return new Duration(seconds, nanos);
717  }
718  functionOverload(`duration(string): google.protobuf.Duration`, (s) => parseDuration(s));
719  functionOverload(`google.protobuf.Duration.getHours(): int`, (d) => d.getHours());
720  functionOverload(`google.protobuf.Duration.getMinutes(): int`, (d) => d.getMinutes());
721  functionOverload(`google.protobuf.Duration.getSeconds(): int`, (d) => d.getSeconds());
722  functionOverload(`google.protobuf.Duration.getMilliseconds(): int`, (d) => d.getMilliseconds());
723  register(registry);
724}
725function stringSize(str) {
726  let count = 0;
727  for (const c of str)
728    count++;
729  return count;
730}
731
732// design/v0.2-custom-rules/node_modules/@marcbachmann/cel-js/lib/registry.js
733class Type {
734  #name;
735  constructor(name) {
736    this.#name = name;
737    objFreeze(this);
738  }
739  get name() {
740    return this.#name;
741  }
742  get [Symbol.toStringTag]() {
743    return `Type<${this.#name}>`;
744  }
745  toString() {
746    return `Type<${this.#name}>`;
747  }
748}
749var TYPES = {
750  string: new Type("string"),
751  bool: new Type("bool"),
752  int: new Type("int"),
753  uint: new Type("uint"),
754  double: new Type("double"),
755  map: new Type("map"),
756  list: new Type("list"),
757  bytes: new Type("bytes"),
758  null_type: new Type("null"),
759  type: new Type("type")
760};
761var optionalType = new Type("optional");
762var valueTypeMatchers = {
763  dyn(v, ev) {
764    switch (typeof v) {
765      case "string":
766      case "bigint":
767      case "number":
768      case "boolean":
769        return true;
770      case "object":
771        switch (v ? v.constructor : v) {
772          case null:
773          case undefined:
774          case Object:
775          case Map:
776          case Array:
777          case Set:
778            return true;
779          default:
780            if (ev.objectTypesByConstructor.get(v.constructor))
781              return true;
782        }
783    }
784    return !!ev.debugType(v);
785  },
786  string(v) {
787    return typeof v === "string";
788  },
789  int(v) {
790    return typeof v === "bigint";
791  },
792  double(v) {
793    return typeof v === "number";
794  },
795  bool(v) {
796    return typeof v === "boolean";
797  },
798  null(v) {
799    return v === null;
800  },
801  bytes(v) {
802    return v instanceof Uint8Array;
803  },
804  uint(v) {
805    return v instanceof UnsignedInt;
806  },
807  type(v) {
808    return v instanceof Type;
809  },
810  list(v) {
811    switch (v?.constructor) {
812      case Array:
813      case Set:
814        return true;
815      default:
816        return false;
817    }
818  },
819  map(v) {
820    switch (typeof v === "object" && v ? v.constructor : null) {
821      case undefined:
822      case Object:
823      case Map:
824        return true;
825      default:
826        return false;
827    }
828  },
829  optional(v) {
830    return v instanceof Optional;
831  },
832  message(v, ev) {
833    return this === ev.debugType(v);
834  }
835};
836valueTypeMatchers.param = valueTypeMatchers.dyn;
837
838class TypeDeclaration {
839  #matchesCache = new WeakMap;
840  constructor({ kind, type, name, keyType, valueType }) {
841    this.kind = kind;
842    this.type = type;
843    this.name = name;
844    this.keyType = keyType;
845    this.valueType = valueType;
846    this.unwrappedType = kind === "dyn" && valueType ? valueType.unwrappedType : this;
847    this.wrappedType = kind === "dyn" ? this : _createDynType(this.unwrappedType);
848    this.hasDynType = this.kind === "dyn" || this.valueType?.hasDynType || this.keyType?.hasDynType || false;
849    this.hasPlaceholderType = this.kind === "param" || this.keyType?.hasPlaceholderType || this.valueType?.hasPlaceholderType || false;
850    if (kind === "list")
851      this.fieldLazy = this.#getListField;
852    else if (kind === "map")
853      this.fieldLazy = this.#getMapField;
854    else if (kind === "message")
855      this.fieldLazy = this.#getMessageField;
856    else if (kind === "optional")
857      this.fieldLazy = this.#getOptionalField;
858    this.matchesValueType = valueTypeMatchers[name] || valueTypeMatchers[kind];
859    objFreeze(this);
860  }
861  isDynOrBool() {
862    return this.type === "bool" || this.kind === "dyn";
863  }
864  isEmpty() {
865    return this.valueType && this.valueType.kind === "param";
866  }
867  unify(r, t2) {
868    const t1 = this;
869    if (t1 === t2 || t1.kind === "dyn" || t2.kind === "param")
870      return t1;
871    if (t2.kind === "dyn" || t1.kind === "param")
872      return t2;
873    if (t1.kind !== t2.kind)
874      return null;
875    if (!(t1.hasPlaceholderType || t2.hasPlaceholderType || t1.hasDynType || t2.hasDynType))
876      return null;
877    const valueType = t1.valueType.unify(r, t2.valueType);
878    if (!valueType)
879      return null;
880    switch (t1.kind) {
881      case "optional":
882        return r.getOptionalType(valueType);
883      case "list":
884        return r.getListType(valueType);
885      case "map":
886        const keyType = t1.keyType.unify(r, t2.keyType);
887        return keyType ? r.getMapType(keyType, valueType) : null;
888    }
889  }
890  templated(r, bind) {
891    if (!this.hasPlaceholderType)
892      return this;
893    switch (this.kind) {
894      case "dyn":
895        return this.valueType.templated(r, bind);
896      case "param":
897        return bind?.get(this.name) || this;
898      case "map":
899        return r.getMapType(this.keyType.templated(r, bind), this.valueType.templated(r, bind));
900      case "list":
901        return r.getListType(this.valueType.templated(r, bind));
902      case "optional":
903        return r.getOptionalType(this.valueType.templated(r, bind));
904      default:
905        return this;
906    }
907  }
908  toString() {
909    return this.name;
910  }
911  #getOptionalField(obj, key, ast, ev) {
912    obj = obj instanceof Optional ? obj.orValue() : obj;
913    if (obj === undefined)
914      return OPTIONAL_NONE;
915    const type = ev.debugType(obj);
916    try {
917      return Optional.of(type.fieldLazy(obj, key, ast, ev));
918    } catch (e) {
919      if (e instanceof EvaluationError)
920        return OPTIONAL_NONE;
921      throw e;
922    }
923  }
924  #getMessageField(obj, key, ast, ev) {
925    const message = obj ? ev.objectTypesByConstructor.get(obj.constructor) : undefined;
926    if (!message)
927      return;
928    const type = message.fields ? message.fields[key] : dynType;
929    if (!type)
930      return;
931    const value = obj instanceof Map ? obj.get(key) : obj[key];
932    if (value === undefined)
933      return;
934    if (type.matchesValueType(value, ev))
935      return value;
936    throw evaluationError("field_type_mismatch", `Field '${key}' is not of type '${type}', got '${ev.debugType(value)}'`, ast);
937  }
938  #getMapField(obj, key, ast, ev) {
939    const value = obj instanceof Map ? obj.get(key) : obj && hasOwn(obj, key) ? obj[key] : undefined;
940    if (value === undefined)
941      return;
942    if (this.valueType.matchesValueType(value, ev))
943      return value;
944    throw evaluationError("field_type_mismatch", `Field '${key}' is not of type '${this.valueType}', got '${ev.debugType(value)}'`, ast);
945  }
946  #getListElementAtIndex(list, pos) {
947    switch (list?.constructor) {
948      case Array:
949        return list[pos];
950      case Set: {
951        let i = 0;
952        for (const item of list) {
953          if (i++ !== pos)
954            continue;
955          return item;
956        }
957      }
958    }
959  }
960  #getListField(obj, key, ast, ev) {
961    if (typeof key === "bigint")
962      key = Number(key);
963    else if (typeof key !== "number")
964      return;
965    const value = this.#getListElementAtIndex(obj, key);
966    if (value === undefined) {
967      if (!obj)
968        return;
969      throw evaluationError("index_out_of_bounds", `No such key: index out of bounds, index ${key} ${key < 0 ? "< 0" : `>= size ${obj.length || obj.size}`}`, ast);
970    }
971    if (this.valueType.matchesValueType(value, ev))
972      return value;
973    throw evaluationError("list_item_type_mismatch", `List item with index '${key}' is not of type '${this.valueType}', got '${ev.debugType(value)}'`, ast);
974  }
975  fieldLazy() {}
976  field(obj, key, ast, ev) {
977    const v = this.fieldLazy(obj, key, ast, ev);
978    if (v !== undefined)
979      return v;
980    throw evaluationError("no_such_key", `No such key: ${key}`, ast);
981  }
982  matchesBoth(other) {
983    return this.matches(other) && other.matches(this);
984  }
985  matches(o) {
986    const s = this.unwrappedType;
987    o = o.unwrappedType;
988    if (s === o || s.kind === "dyn" || o.kind === "dyn" || o.kind === "param")
989      return true;
990    return this.#matchesCache.get(o) ?? this.#matchesCache.set(o, this.#matches(s, o)).get(o);
991  }
992  #matches(s, o) {
993    switch (s.kind) {
994      case "dyn":
995      case "param":
996        return true;
997      case "list":
998        return o.kind === "list" && s.valueType.matches(o.valueType);
999      case "map":
1000        return o.kind === "map" && s.keyType.matches(o.keyType) && s.valueType.matches(o.valueType);
1001      case "optional":
1002        return o.kind === "optional" && s.valueType.matches(o.valueType);
1003      default:
1004        return s.name === o.name;
1005    }
1006  }
1007}
1008var macroEvaluateErr = `have a .callAst property or .evaluate(checker, macro, ctx) method.`;
1009var macroTypeCheckErr = `have a .callAst property or .typeCheck(checker, macro, ctx) method.`;
1010function wrapMacroExpander(name, handler) {
1011  const p = `Macro '${name}' must`;
1012  return function macroExpander(opts) {
1013    const macro = handler(opts);
1014    if (!macro || typeof macro !== "object")
1015      throw new Error(`${p} return an object.`);
1016    if (macro.callAst)
1017      return macro;
1018    if (!macro.evaluate)
1019      throw new Error(`${p} ${macroEvaluateErr}`);
1020    if (!macro.typeCheck)
1021      throw new Error(`${p} ${macroTypeCheckErr}`);
1022    return macro;
1023  };
1024}
1025
1026class VariableDeclaration {
1027  constructor(name, type, description, value) {
1028    this.name = name;
1029    this.type = type;
1030    this.description = description ?? null;
1031    this.constant = value !== undefined;
1032    this.value = value;
1033    objFreeze(this);
1034  }
1035}
1036
1037class FunctionDeclaration {
1038  constructor({ name, receiverType, returnType, handler, description, params, async }) {
1039    if (typeof name !== "string")
1040      throw new Error("name must be a string");
1041    if (typeof handler !== "function")
1042      throw new Error("handler must be a function");
1043    this.name = name;
1044    this.async = isAsync(handler, async);
1045    this.receiverType = receiverType ?? null;
1046    this.returnType = returnType;
1047    this.description = description ?? null;
1048    this.params = params;
1049    this.argTypes = params.map((p) => p.type);
1050    this.macro = this.argTypes.includes(astType);
1051    const receiverString = receiverType ? `${receiverType}.` : "";
1052    this.signature = `${receiverString}${name}(${this.argTypes.join(", ")}): ${returnType}`;
1053    this.handler = this.macro ? wrapMacroExpander(this.signature, handler) : handler;
1054    this.partitionKey = `${receiverType ? "rcall" : "call"}:${name}:${params.length}`;
1055    this.hasPlaceholderType = this.returnType.hasPlaceholderType || this.receiverType?.hasPlaceholderType || this.argTypes.some((t) => t.hasPlaceholderType) || false;
1056    objFreeze(this);
1057  }
1058  matchesArgs(argTypes) {
1059    return argTypes.length === this.argTypes.length && this.argTypes.every((t, i) => t.matches(argTypes[i])) ? this : null;
1060  }
1061}
1062
1063class OperatorDeclaration {
1064  constructor({ op, leftType, rightType, handler, returnType, async }) {
1065    this.operator = op;
1066    this.leftType = leftType;
1067    this.rightType = rightType || null;
1068    this.handler = handler;
1069    this.async = isAsync(handler, async);
1070    this.returnType = returnType;
1071    if (rightType)
1072      this.signature = `${leftType} ${op} ${rightType}: ${returnType}`;
1073    else
1074      this.signature = `${op}${leftType}: ${returnType}`;
1075    this.hasPlaceholderType = this.leftType.hasPlaceholderType || this.rightType?.hasPlaceholderType || false;
1076    objFreeze(this);
1077  }
1078  equals(other) {
1079    return this.operator === other.operator && this.leftType === other.leftType && this.rightType === other.rightType;
1080  }
1081}
1082function _createListType(valueType) {
1083  return new TypeDeclaration({
1084    kind: "list",
1085    name: `list<${valueType}>`,
1086    type: "list",
1087    valueType
1088  });
1089}
1090function _createPrimitiveType(name) {
1091  return new TypeDeclaration({ kind: "primitive", name, type: name });
1092}
1093function _createMessageType(name) {
1094  return new TypeDeclaration({ kind: "message", name, type: name });
1095}
1096function _createDynType(valueType) {
1097  const name = valueType ? `dyn<${valueType}>` : "dyn";
1098  return new TypeDeclaration({ kind: "dyn", name, type: name, valueType });
1099}
1100function _createOptionalType(valueType) {
1101  const name = `optional<${valueType}>`;
1102  return new TypeDeclaration({ kind: "optional", name, type: "optional", valueType });
1103}
1104function _createMapType(keyType, valueType) {
1105  return new TypeDeclaration({
1106    kind: "map",
1107    name: `map<${keyType}, ${valueType}>`,
1108    type: "map",
1109    keyType,
1110    valueType
1111  });
1112}
1113function _createPlaceholderType(name) {
1114  return new TypeDeclaration({ kind: "param", name, type: name });
1115}
1116var dynType = _createDynType();
1117var astType = _createPrimitiveType("ast");
1118var listType = _createListType(dynType);
1119var mapType = _createMapType(dynType, dynType);
1120var celTypes = {
1121  string: _createPrimitiveType("string"),
1122  bool: _createPrimitiveType("bool"),
1123  int: _createPrimitiveType("int"),
1124  uint: _createPrimitiveType("uint"),
1125  double: _createPrimitiveType("double"),
1126  bytes: _createPrimitiveType("bytes"),
1127  dyn: dynType,
1128  null: _createPrimitiveType("null"),
1129  type: _createPrimitiveType("type"),
1130  optional: _createOptionalType(dynType),
1131  list: listType,
1132  "list<dyn>": listType,
1133  map: mapType,
1134  "map<dyn, dyn>": mapType
1135};
1136for (const t of [celTypes.string, celTypes.double, celTypes.int]) {
1137  const list = _createListType(t);
1138  const map = _createMapType(celTypes.string, t);
1139  celTypes[list.name] = list;
1140  celTypes[map.name] = map;
1141}
1142Object.freeze(celTypes);
1143
1144class Candidates {
1145  returnType = null;
1146  async = false;
1147  macro = false;
1148  #matchCache = null;
1149  #checkCache = null;
1150  declarations = [];
1151  constructor(registry) {
1152    this.registry = registry;
1153  }
1154  [Symbol.iterator]() {
1155    return this.declarations[Symbol.iterator]();
1156  }
1157  add(decl) {
1158    this.returnType = (this.returnType || decl.returnType).unify(this.registry, decl.returnType) || dynType;
1159    if (decl.macro)
1160      this.macro = decl;
1161    if (decl.async && !this.async)
1162      this.async = true;
1163    this.declarations.push(decl);
1164    this.#matchCache?.clear();
1165    this.#checkCache?.clear();
1166  }
1167  findFunction(argTypes, receiverType = null) {
1168    for (let i = 0;i < this.declarations.length; i++) {
1169      const match = this.#matchesFunction(this.declarations[i], argTypes, receiverType);
1170      if (match)
1171        return match;
1172    }
1173    return null;
1174  }
1175  findUnaryOverload(left) {
1176    const cached = (this.#matchCache ??= new Map).get(left);
1177    if (cached !== undefined)
1178      return cached;
1179    let value = false;
1180    for (const decl of this.declarations) {
1181      if (decl.leftType !== left)
1182        continue;
1183      value = decl;
1184      break;
1185    }
1186    this.#matchCache.set(left, value);
1187    return value;
1188  }
1189  findBinaryOverload(left, right) {
1190    if (left.kind === "dyn" && left.valueType)
1191      right = right.wrappedType;
1192    else if (right.kind === "dyn" && right.valueType)
1193      left = left.wrappedType;
1194    return (this.#matchCache ??= new Map).get(left)?.get(right) ?? this.#cacheBinary(this.#matchCache, left, right, this.#findBinaryUncached(left, right));
1195  }
1196  checkBinaryOverload(left, right) {
1197    return (this.#checkCache ??= new Map).get(left)?.get(right) ?? this.#cacheBinary(this.#checkCache, left, right, this.#checkBinaryUncached(left, right));
1198  }
1199  #cacheBinary(c, l, r, v) {
1200    return (c.get(l) || c.set(l, new Map).get(l)).set(r, v), v;