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…

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.
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.
| Tool | What it does |
|---|---|
mcp__ciel-mode__run | Runs a program and returns its result. |
mcp__ciel-mode__tools | Lists 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.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.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
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.claude -p) or the dialog dismissed, the program doesn't run, and Claude is told to make the calls directly instead./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.
| Option | Default | What 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_allow | empty | A regex over full tool names: tools a program may call without the approval dialog, even when your rules would ask. Empty means none. | |||||
deny_direct | off | Deny 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_calls | 100 | Tool calls one program may make; the program is stopped past it. callEach is checked before it starts. | |||||
concurrency | 8 | Calls callEach runs at once. | |||||
max_output | 20000 | Characters of a result Claude reads; the rest is cut with a note. | |||||
max_vars | 20 | var 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.
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:
| Question | Without ciel-mode | ciel-mode | ciel-mode + deny_direct |
|---|---|---|---|
| Q1: count open stale issues and their top labels (one 81 KB list) | 74k, 94k, 74k | 61k, 83k, 252k | 61k, 82k, 81k |
| Q2: top 5 issues by comments, then each one's commenters (list + 5 calls) | 104k, 103k, 103k | 200k, 199k, 153k | 172k, 149k, 159k |
| Q3: top 3 commenters across 40 full threads (40 calls, 115 KB in all) | 96k, 96k, 96k | 82k, 103k, 83k | 82k, 124k, 125k |
What this shows:
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.
x.__proto__ and x.constructor are missing keys), tool data is copied without prototypes, and parse limits cap a program's size and nesting.$.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).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).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.
Requires Claude Code v2.1.287 or later. Load it from a checkout:
claude --plugin-dir /path/to/ciel-mode
claude plugin validate . and claude plugin test (unit and test-kit tests in tests/)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.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.
hooks/register.ts 232 lines1// 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}
232hooks/approval.ts 50 lines1// 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}
50hooks/catalog.ts 68 lines1// 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}
68hooks/program.ts 443 lines1// 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}
443hooks/spill.ts 44 lines1// 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}
44hooks/stdlib.ts 197 lines1// 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 })
197hooks/vendor/cel/cel.js 4552 lines1// 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;