nlir·natural-language IR Guide Spec Workspace Spec lab Showcase GitHub ↗

nlir — natural-language IR

nlir transpiles a terse, sigil-laden shorthand into fluent English. The shorthand is an intermediate representation: it is tokenised, parsed into a DAG using a config-defined grammar, and evaluated over a small stack machine with a tiny type system, where each operator is realised either deterministically (mechanical string/number expansion) or via an LLM call (a structured text-transformation).

Invocation — typically from a coding agent's prompt window:

nlir -e 'EXPR'

The engine ships only a tiny set of builtins (stack / context / indexing / assignment / arithmetic / coercion / list plumbing). Everything else — the operator vocabulary, their fixity/priority/arity/types, the models, the prompts, the coercions, the tests — lives in ~/.config/nlir/config.yaml. The binary is a small VM; the language is config.


Mental model

EXPR ──tokenise──▶ tokens ──parse──▶ DAG ──schedule/eval──▶ English
                             (grammar from config)   (stack machine; types+coercion; per-op det|llm; parallel)

Runtime state (two namespaces)

Name Owner Lifetime Access
context (NLIR_CONTEXT env, or a context file) harness / scripts / nlir set / in-expr = transient by default; persistent with a context file read $name / ^; write LHS=RHS (immediate) or set/append-message
stack nlir, internal one run ; pushes, $ / $N peek, nullary-op pops

Types & coercion

Every value has a type: string (default), number, bool, list, dict (an insertion-ordered key=value map — see Dictionaries & accessors below), form (a quoted {…} expression — see metaprogramming below).

Coercion that cannot produce the target type is a loud error (the LLM path is constrained by its output JSON schema, so it returns a valid typed value or fails). list → number is an error. A dict coerces to string as its key=value pairs (joined with _sep); dict → number/bool is an error.

Coercion is part of the same expand/contract machinery as operators, and its results honour _cache.


Modes

det (no network) or llm. Default defaults.mode, override --mode. Realisation resolution for an operator:

  1. command: (subprocess, det) or reduce: (builtin numeric add/sub/mul/div/pow, det) → run it;
  2. else mode det → template: / join:;
  3. else mode llm → model: + prompt:.

command/reduce/coercion math are deterministic regardless of mode; only string transformations differ by mode.


Builtins (fixed engine layer, not config)

Sequencing & stack

Context: read & assign

Message indexing (role-filtered views of _messages)

Structure

Forms, application & macros (metaprogramming)

Dictionaries & accessors

Data trees: loading and navigating structured data

Context values keep their JSON structure: objects are insertion-ordered dicts, arrays are lists, all the way down (a context file, NLIR_CONTEXT, the wasm contextJson, and assigned dicts round-tripping through context). Load a whole file as a native value with --bind NAME=PATH (.json and .yaml/.yml load as data; any other file — Markdown, text — binds as one string; repeatable; transient — never written to a context file):

nlir --bind spec=spec.json -f lib.nlir -e '$join%({$0.id}↦$spec.items, ", ")'

-f PATH runs a program file (repeatable; files then any -e are joined as ;-separated statements, so a library file can define named forms used later). In program files, lines whose first non-blank characters are -- are comments.

Deterministic data builtins (all loud on bad input; a context key of the same name shadows them):

builtin result
$keys%d / $values%d a dict's keys / values (a list's indices / items)
$entries%d [{key=k, value=v}, …] — map a dict like a list of records
$get%(c, k) / $get%(c, k, default) dynamic .: dict key or list/string index ($get%($nodes, $id) for keys with spaces); missing → default, else loud
$has%(c, k) Bool: is k a key / valid index
$join%(list, sep) items rendered and joined with sep
$flat%list flatten one level — the concat recursive tree walks need (and list append: $flat%[$xs, [x]])
$zip%(a, b, …) [[a0, b0], [a1, b1], …], truncated to the shortest — pair parallel results with their inputs
$slice%(c, start[, end]) sub-list / substring, end-exclusive, negatives from the end, clamped
$json%v / $json%(v, indent) the value as JSON text (dicts stay ordered)
$type%v string · number · bool · list · dict · form
$trim%s · $lower%s · $upper%s · $lines%s text plumbing ($lines splits on newlines)
$replace%(s, from, to) literal replace-all
$match%(s, re) first regex match — the whole match, its one group, or a list of groups; '' if none
$matches%(s, re) every match (same shape per match)
$sub%(s, re, repl) regex replace-all ($1, ${name} refer to groups)

Write regexes in single quotes ('\d+'): double-quoted strings process \ escapes. A recursive named form walks a tree: ids={$flat%[[$0.id], $flat%({$ids%$0}↦$0.children)]}; $ids%$tree.

Higher-order list builtins ($map / $fold / $scan / $filter)

Deterministic, model-free list combinators (bd-14af74): each takes a form as its first argument and applies it structurally — the form's body only touches the model if it itself contains an llm op. $map has a keyboard-typable alias (<$>) and a visual alias (↦); $fold has ⊘ (see the operator table). $scan and $filter are builtin-only. These aliases are ordinary config.yaml operators using builtin: — the engine does not hardcode their spellings.

Together these are the deterministic composable core — statistics (mean = $fold%(…)/$len%…), running series, and predicate-filtered aggregates — all exact, model-free, and CI-gateable.

Set-notation builtins ($elem / $union / $inter / $diff)

Deterministic, total set algebra over values (bd-49d65a). Like $sort/$contains, element identity is the rendered value (trimmed), so lists of numbers, strings, or mixed values compare predictably. $union/$inter/$diff return a list; results are order-preserving (first-occurrence order of the left operand) and deduped. Coercion for the algebra ops: a list spreads to its items, a dict spreads to its keys (set ops on a dict operate on its key set), and a scalar is a one-element set.

They compose with the rest of the value-builtin family: $if%($elem%('ERROR',$0),'page','ok') gates on membership; $nth%(0,$sort%$union%(a,b)) = the least element of a merged set. Sigil aliases (∪ ∩ ∈ ∖) are a planned follow-up (config-operator layer).

Length builtin ($len)

$len%coll — the length/cardinality of coll as a Number (bd-b9b491): a list → item count, a dict → key count, and anything else (string/number/bool/form) → the Unicode character count of its rendered form (so a string's length is its chars: $len%'hello' → 5, $len%42 → 2). Total + deterministic — the model-free counting primitive: $len%[2,3,5,7,11] → 5, $len%{a=1,b=2} → 2, and set cardinality $len%($inter%([2,3,5,7,11],[1,3,5,7,9])) → 3. A spread list $len%[a,b,c] (3 args) counts 3; a single scalar in a single-element list $len%[x] is indistinguishable from $len%x after the spread.

Predicate builtins ($gt / $lt / $not)

$gt%(a,b) / $lt%(a,b) — STRICT numeric comparison (bd-89b3d0), a > b / a < b, returning a Bool. They fill the strict-comparison gap: the >/< sigils are taken (expand/shorten), and the >=/<= operators are non-strict, so $gt%(3,3) → false where 3>=3 → true. Operands coerce to number like >=/<=.

$not%(x) — boolean NOT: negates x's truthiness and returns a Bool. Unlike ! (which is textual negation, e.g. !(3>=5) → the string "not (false)"), $not inverts any Bool — including a fuzzy ~> result — so it composes in fold-fusion count-if-NOT. Since Bools coerce true→1 under +, map a predicate then fold to count: $fold%({$0+$1},$map%({$lt%($0,5)},0..10)) → 5. The sigil alias ¬ (config operator, prefix) completes ∧ ∨ ¬ — see the operator table.

Rounding builtins ($floor / $ceil / $round)

Fundamental numeric primitives (bd-9004bb): $floor%x → ⌊x⌋, $ceil%x → ⌈x⌉, $round%x → nearest integer (half away from zero). Operand coerces to number. They keep $nth strict (it rejects a fractional index) while letting median fall out of sort + index — no special $median function: $nth%($floor%($len%L/2),$sort%L) is the middle element for odd length. The two middle indices are $floor and $ceil of ($len-1)/2, so one expression gives the median for both parities (mean of the two middles when even). Same principle as max = sort-then-last: composable primitives over special-cased sugar. A sigil alias (⌊⌋) is a config-operator follow-up.

$sqrt%x (bd-92cbf0) — square root (f64::sqrt; negative operand → NaN). Same unary-numeric family. It completes stddev — variance already composes from $fold/$map/$len, so stddev = $sqrt%variance — and unlocks Pythagoras/hypotenuse ($sqrt%(3*3+4*4) → 5), Euclidean distance, and geometric mean. A √ sigil is a config-operator follow-up.

Modulo builtin ($mod)

$mod%(a,b) — mathematical modulo (mathy golf): the remainder of a divided by b via rem_euclid, so the result is non-negative in [0, |b|) — $mod%(7,3) → 1, $mod%(0-1,3) → 2 (not -1, unlike C-style %). Operands coerce to number like $gt/$lt; a zero divisor is a loud error. The non-negative convention is what makes number theory compose cleanly: even/odd ($mod%(n,2) → 0/1), divisibility ($mod%(n,k) → 0), cyclic indexing, and Euclid-style GCD. Count evens in 1..10: $len%($filter%({$not%($mod%($0,2))},1..10)) → 5.

Reserved builtin sigils: ; $ ^ = [ ] , ( ) { } % \ ` , the quote chars " ', the escape \. Configured operator sigils (# ! & | ? + - * / ** …) add to this. After ^/$, * _ / are role modifiers and a leading - is a negative index.


Whitespace, escapes & quoting


Interpolation & evaluation timing


Execution graph & parallelism


Output & tracing


Config operators (the language proper)

The canonical operator vocabulary — each realised via template / reduce / command (det) or prompt (llm). These descriptions are the authoritative semantics, mirrored by nlir help and the phrasebook.

String / text operators (llm-realised unless a template/command is given):

op name fixity · arity what it does
# subject prefix · 1 The text's primary subject as a short noun phrase — a topic label, not a definition. Over a list, folds to the common category when the items share one; otherwise lists them per-item.
! not prefix · 1 Negates the claim, clause-wise (!(a&b) = neither); on a lone concept-word, its antonym. Involution: !!x ≈ x.
~ summary prefix · 1 The essence in one short sentence — drops specifics for the gist. Saturates (~~x ≈ ~x); folds a list to its consensus.
@ formal prefix · 1 Rewrites in a formal, professional register, meaning preserved. Saturates after one pass; distributes over &.
: simplify prefix · 1 Rewrites in plain, simple language (strips jargon). The one op that reliably maps per-item over a list.
> expand prefix · 1 Adds detail and explanation (lengthens). Forks over or (keeps paths distinct), integrates over &.
< shorten prefix · 1 Tightens to the information floor — fewest words, but keeps every fact and figure (vs ~ = the gist).
? question postfix · 1 Turns the text into a question. A yes/no question is polarity-neutral (!x? ≈ x?).
& and mixfix · >0 Joins operands into one "X and Y" statement (a plan, not boolean ∧); nullary & folds the premise stack.
| or mixfix · >0 Joins operands into one "X or Y" choice, kept as genuine alternatives.
_ echo infix · 2 Repeats the text N times, space-joined (x_2 = "x x"). The one shell-command-realised op.
. access infix · 2 Polymorphic structural accessor (deterministic): [a,b,c].1→b (0-based, negative from end), {k=v}.k→v, "the".2→e. Loud on out-of-range / missing key. Binds tighter than % ($lib.f%x = ($lib.f)%x); snake_case keys stay whole ($row.first_name).
.. access-semantic infix · 2 The LLM twin of .: reads element N from the sequence described by the text ("planets from the sun"..3→"earth"), generalizing to descriptors. det-stub keeps det-total. Numeric a..b (both integer operands) short-circuits to a deterministic inclusive range list: 1..5→[1,2,3,4,5], 5..1→[5,4,3,2,1] (descending), repurposing the useless numeric-index case.
++ concat mixfix · >0 Concatenate the string operands (deterministic): "foo"++"bar" → "foobar".
// split infix · 2 Split a string by a separator into a list (deterministic): "a,b,c"//"," → [a,b,c].
Δ diff infix · 2 Directional diff — what changed from the first text to the second (added / removed / shifted). Non-commutative (aΔb ≠ bΔa); powers before/after, drift, changelog.
~> implication-check infix · 2 Does the LHS imply the RHS? Integrates over &.
~>? implication-infer mixfix · >0 Infers the implication (the consequent) of its arguments. Integrates over &.
≈ similarity infix · 2 Semantic similarity in [0,1]: how closely the second text reproduces the meaning of the first (reference). det = word-set Jaccard; llm = judged equivalence of claims, conditions and criteria ($gen ≈ $gold >= 0.8).

Numeric operators (operands: number, result: number, reduce:):

op name fixity · arity what it does
+ add mixfix · >0 Sum of the operands (words / units / bases coerce to numbers first).
* mul mixfix · >0 Product of the operands.
- sub infix · 2 Difference, a − b (left-associative).
/ div infix · 2 Quotient, a ÷ b (guards divide-by-zero).
** pow infix · 2 a to the power b — right-associative, so 2**3**2 = 2**(3**2) = 512 (matches math / Python).

Comparison operators (result: bool, reduce:-realised, deterministic):

op name fixity · arity what it does
== equals infix · 2 True when the operands are equal (by rendered value): 5==5 → true, 3==4 → false.
!= not-equals infix · 2 True when the operands differ: 3!=4 → true.
<= at-most infix · 2 True when the first operand is numerically ≤ the second: 3<=5 → true.
>= at-least infix · 2 True when the first operand is numerically ≥ the second: 5>=3 → true.

Higher-order (list) operators (deterministic; configured aliases for the $map / $fold builtins):

op name fixity · arity what it does
<$> mapascii infix · 2 Keyboard-typable map: apply the left form to each item of the right list: {$0*$0}<$>[1,2,3] → [1,4,9]. Alias for $map%; ↦ is its visual twin.
↦ mapop infix · 2 Visual map alias with the same configured builtin: map semantics as <$>.
⊘ foldop infix · 2 Reduce the right list with the left binary form: {$0+$1}⊘[1,2,3,4] → 10. Alias for $fold%.
∪ setunion infix · 2 Set union: a ∪ b — order-preserving, deduped. Deterministic alias for $union% (binary case). Dict operands → their keys.
∩ setinter infix · 2 Set intersection: a ∩ b — items of a also in b (deduped, a-order). Alias for $inter%. Binds tighter than ∪/∖.
∖ setdiff infix · 2 Set difference: a ∖ b — items of a not in b (deduped, a-order). Alias for $diff%. (∖ = U+2216 SET MINUS.)
∈ setelem infix · 2 Membership: x ∈ coll → Bool — list element, dict key, or string substring. Alias for $elem% (item ∈ collection).
∧ booland infix · 2 Logical AND: a ∧ b → Bool, true iff both operands are truthy. First-class conjunction, distinct from compose-& (string weave). Binds tighter than ∨; evaluates both operands (use $if for short-circuit).
∨ boolor infix · 2 Logical OR: a ∨ b → Bool, true iff either operand is truthy. Distinct from compose-| (string choice).
¬ boolnot prefix · 1 Logical NOT: ¬a → Bool, true iff a is falsy. Negates any Bool (incl. a fuzzy ~> result); the boolean twin of ! (textual negation). Binds tighter than ∧ ∨; composes in fold-fusion count-if-NOT. Glyph alias for $not.
√ sqrtop prefix · 1 Square root: √x → the non-negative square root as a Number (deterministic). Enables terse stddev (√variance), Pythagoras √(3*3+4*4)→5, Euclidean distance, geometric mean. Glyph alias for $sqrt.

Instruction-following (generation) — the third category. Here the operand is an instruction to obey, not text to reshape. This is the generative direction of the language: it expresses new thoughts — replies, drafts, answers, "do X" — rather than transforming existing text, and because it composes under &/| like any string op it can build structured generated thoughts, not just emit a single prompt ((=>a)&(=>b) joins two generated sentences). It carries its own generative system frame (operand = instruction), so it obeys instead of describing, even on weaker models — the reason >"write one word" merely expands the request while =>"write one word" writes the word. The frame is an operator property (system: generative, naming a prompts: entry), not a model alias, so => keeps it on any tier: nlir --model haiku -e '=>…' obeys on the fast model.

op name fixity · arity what it does
=> respond prefix · 1 Obeys the instruction in the operand and returns only the result (length/format constraints included). Generative, not transformative; composes under &/|; a bare => on a pipe obeys the piped text as the instruction.

Operands follow the usual quoting rule (§Interpolation): double-quoted operands interpolate context, so =>"one-sentence reply to: $_stdin" folds a piped message into the instruction; single-quoted operands stay literal. This is the reply-generation idiom — pipe a message in, => writes the reply — and because => composes, (=>"warm ack: $_stdin") & (=>"gentle counter: $_stdin") joins two generated sentences.

The algebraic laws above (involution, saturation, ?-absorption, list-folds) are realised-semantics: they hold in llm realisation — the user-facing output — while det mode applies the literal template (!!a → "not not a").


Grammar & parsing

Tokens: bare literal [a-zA-Z0-9]+; numeric literal; quoted literal ('…'/"…"); operator (longest configured op:, so ** before * and <$> as one token); builtin sigils ; $ ^ = [ ] , ( ) ` (with $name/$N, ^/^_/^*/^/, LHS= sub-forms); escapes \x. A builtin sigil may appear later in a configured punctuation operator — <$> is config, not a lexer special case. At a leading $, configured punctuation operators such as $>/$( longest-match before the fallback; $name, $_key, $N, $-N, and bare $ remain reserved stack/context forms and cannot be shadowed by config.

Operator attributes (config): op, arity (1,2,…,>0), fixity (prefix/postfix/infix/mixfix), priority (higher binds tighter, default 9), assoc (left/right, default left; only meaningful for infix), operands/result types.

Precedence (config-tunable): ^ indexing is tightest; prefix unary (# ! ¬ √) binds above all binary; binary follows normal math — ** > * / > + - — then string & |; the postfix ? is the deliberate loose exception (binds everything to its left); = is loosest. Concretely: ^ 20 · . .. 16 · # ! ¬ √ 14 · ** // 13 · * / 12 · + - 11 · ++ 10 · & | 9 · <$> ↦ ⊘ 8 · ∩ 7 · ∪ ∖ 6 · ∈ == != <= >= 5 · ∧ 4 · ∨ 3 · ? 1 · = 0 (nlir help lists the exhaustive per-op priority; this prose summarises the tiers). prefix takes one right operand; postfix takes leftward to its priority; variadic flattens; mixfix unifies infix/list/nullary; ties → prefix > infix > postfix. Associativity: infix operators are left-associative by default (a-b-c = (a-b)-c, 16/4/2 = (16/4)/2); ** sets assoc: right, so exponentiation is right-associative (2**3**2 = 2**(3**2) = 512), matching normal math and Python. Mixfix operators flatten same-op chains into one n-ary node, so associativity does not affect them. (…) overrides and is preserved in output.


Worked examples

Assume the last assistant message (^-1) is about a rust-rewrite.

Expression Reading
^-1 last assistant message
^_-1 / ^/-1 last user / last system message
(1+1)^(5+5) assistant messages 2..10, joined with _sep
1+1 "2" (number, stringified on output)
#^-1 "a rust-rewrite"
!#^-1 "not a rust-rewrite"
#0^3;#^-1;& "subject(0..3), and a rust-rewrite"
&[a,b,c] "a, b, and c"
!(a&b) "not (a and b)" — parens preserved
'one two' "one two"
"the subject is $k" interpolates $k at eval time
_sep=\ ;[a,b] "a b"
`k=#^-1;$k serial: store subject as k, then read it

CLI surface

nlir -e 'EXPR' [--quiet] [--mode det|llm] [--model haiku | --model medium=haiku,large=opus] [--parallelism 8] [--dry-run]
nlir -f lib.nlir [-f prog.nlir] [-e 'EXPR']   # program files (`--` comment lines)
nlir … --bind NAME=PATH           # JSON/YAML → native dict/list `$NAME`; other files → string (transient)

# context read (precedence: --context-file › --session-file › NLIR_CONTEXT env › default file)
# (default file loads for the REPL / set / get; a one-shot `-e` skips it — bd-85c49d)
nlir … --context-file PATH
nlir … --session-file PATH        # e.g. Pi session: roles kept, tool calls dropped

# context write (immediate; on active/default context file); set = key replacement
nlir set KEY VALUE
nlir set '{"k":"v","_messages":[…]}'   # each named key replaced (not deep-merged)
nlir get KEY
nlir append-message [--role user] "text"

# interactive: one expr per submission, trailing `\` continues; :cmd == `nlir cmd`
nlir repl [--context-file F] [--raw]     # :set/:get/:append-message inside repl

# plumbing (CLI template stack)
nlir parse 'EXPR'
nlir test
nlir mcp stdio        # mcp-cli
nlir self-update …    # updatable-cli
nlir feedback …       # feedback-cli

Pi plugin: a prompt starting with | → the remainder is expanded as nlir first; the plugin append-messages each turn and pipes shorthand into a long-lived nlir repl --raw.


Example ~/.config/nlir/config.yaml

defaults:
  mode: llm
  model: haiku
  parallelism: 8

context:
  env: NLIR_CONTEXT
  file_default: ~/.config/nlir/context.json
  messages:
    key: _messages
    role_field: role
    content_field: content
    views:                      # ^ / ^_ / ^* / ^/
      default: [assistant]
      user:    [user]
      all:     [user, assistant, system]
      system:  [system]
  defaults:
    _sep: "\n"                  # list & message-range → text separator
    _cache: true

types:                          # coercion targets (deterministic parse first, LLM fallback)
  number:
    model: haiku
    prompt: |
      Interpret the text inside <text> as a single number. Return only the number.

      %
    schema: { type: object, properties: { result: { type: number } }, required: [result] }
  bool:
    model: haiku
    prompt: |
      Interpret the text inside <text> as true or false.

      %
    schema: { type: object, properties: { result: { type: boolean } }, required: [result] }
  # string: numbers/bools/lists stringify deterministically (lists join with _sep)

sessions:
  pi:
    format: pi
    keep_roles: [user, assistant]
    drop_tool_messages: true
    role_field: role
    content_field: content

models:
  haiku:
    type: anthropic_messages
    base_url: https://api.anthropic.com/v1
    api_key: $ANTHROPIC_API_KEY
    model: claude-haiku-4-5
    format: json
    result_field: result
    messages:
      - role: system
        content: |
          ${NLIR_SYSTEM_PROMPT}
          ${NLIR_STRUCTURED_PROMPT}
      - role: user
        content: |
          ${NLIR_PROMPT}
    output_config:
      format:
        type: json_schema
        schema:
          type: object
          properties: { result: { type: string } }
          required: [result]
          additionalProperties: false
  sonnet:
    type: command
    format: json
    result_field: result
    command: >
      claude --model claude-sonnet-5
        --system-prompt "${NLIR_SYSTEM_PROMPT}"
        --append-system-prompt "${NLIR_STRUCTURED_PROMPT}"
        --output-format json --json-schema '{"result": "string"}'
        --print "${NLIR_PROMPT}"
  gpt-5.5:
    type: command
    format: text
    command: >
      pi --no-extensions --no-session --model github_copilot/gpt-5.5
        --system-prompt "${NLIR_SYSTEM_PROMPT}"
        --append-system-prompt "${NLIR_UNSTRUCTURED_PROMPT}"
        --print "${NLIR_PROMPT}"

prompts:
  system:      { env: NLIR_SYSTEM_PROMPT, text: "Perform the following text transformation task.\nRespond with the transformed text in plain language.\nDo not include <text> tags in your output." }
  structured:  { env: NLIR_STRUCTURED_PROMPT, text: "Format your response as JSON containing only a \"result\" field with the transformation, no preamble." }
  unstructured:{ env: NLIR_UNSTRUCTURED_PROMPT, text: "Output only the text result of your transformation with no preamble." }

operators:  # `%` = operand under replacement; `%%` = literal %
  subject:  { op: "#", arity: 1,   fixity: prefix,  model: haiku, prompt: "Extract the primary subject of the text inside <text> as a short noun phrase. Return only that noun phrase.\n\n%" }
  not:      { op: "!", arity: 1,   fixity: prefix,  template: "not %", model: haiku, prompt: "Negate the text inside <text>...</text>, changing nothing else.\n\n%" }
  and:      { op: "&", arity: ">0", fixity: mixfix, join: " and ", model: sonnet, prompt: "Combine the <text> items with an \"and\" connective into one text meaning \"t_0 and t_1 and ...\".\n\n%" }
  or:       { op: "|", arity: ">0", fixity: mixfix, join: " or ",  model: sonnet, prompt: "Combine the <text> items with an \"or\" connective into one text meaning \"t_0 or t_1 or ...\".\n\n%" }
  question: { op: "?", arity: 1,   fixity: postfix, priority: 0, template: "is it the case that %?", model: gpt-5.5, prompt: "Convert the text inside <text> into a question, preserving subject and meaning.\n\n%" }
  echo:
    op: "_"
    arity: 2
    fixity: infix
    command: |
      t="${NLIR_ARGS[0]}"; n="${NLIR_ARGS[1]}"; out="$t"
      for i in $(seq 1 $((n-1))); do out="$out $t"; done
      printf '%s' "$out"
  add: { op: "+",  arity: ">0", fixity: mixfix, priority: 11, operands: number, result: number, reduce: add }
  mul: { op: "*",  arity: ">0", fixity: mixfix, priority: 12, operands: number, result: number, reduce: mul }
  sub: { op: "-",  arity: 2,   fixity: infix,  priority: 11, operands: number, result: number, reduce: sub }
  div: { op: "/",  arity: 2,   fixity: infix,  priority: 12, operands: number, result: number, reduce: div }
  pow: { op: "**", arity: 2,   fixity: infix,  priority: 13, assoc: right, operands: number, result: number, reduce: pow }

tests:
  det-echo:   { mode: det, expr: "xxx_2",         expected: "xxx xxx" }
  det-not:    { mode: det, expr: "!foo",           expected: "not foo" }
  det-and:    { mode: det, expr: "a&b&c",          expected: "a and b and c" }
  det-group:  { mode: det, expr: "!(a&b)",         expected: "not (a and b)" }
  det-quote:  { mode: det, expr: "'one two'",      expected: "one two" }
  det-sep:    { mode: det, expr: "_sep=\\ ;[a,b]", expected: "a b" }
  det-assign: { mode: det, expr: "k=foo;$k",       expected: "foo" }
  num-add:    { mode: det, expr: "1+2+3",          expected: "6" }
  num-index:  { mode: det, expr: "(1+1)**3",       expected: "8" }
  num-powassoc:{ mode: det, expr: "2**3**2",        expected: "512" }
  msg:
    mode: det
    context:
      _messages:
        - { role: user,      content: "hi" }
        - { role: assistant, content: "in rust" }
    expr: "^-1"
    expected: "in rust"

Implementation / project shape

Rust, Nix-flake-managed, on the harryaskham CLI template stack (mature examples under ~/.cacophony/daemon/checkouts/…): mcp-cli (nlir mcp stdio — the whole surface is agent-callable), updatable-cli (nlir self-update), feedback-cli (nlir feedback). Behaviour above is the spec; this is the scaffolding pattern.