@plurnk/plurnk-grammar
v1.0.5
Published
ANTLR4 grammar for the Plurnk LLM agent protocol
Readme
plurnk-grammar
Parser for the Plurnk protocol — a HEREDOC-style DSL for LLM agents.
install
npm install @plurnk/plurnk-grammarRequires Node ≥ 25 (native TypeScript support).
use
import { PlurnkParser } from "plurnk-grammar";
const result = PlurnkParser.parse(input);
// result.items: Array<{kind:"statement"|"error"|"text", ...}>
// result.unparsedTail?: { from, reason }Discriminate on item.kind. For statement items, narrow on statement.op (one of FIND READ EDIT COPY MOVE OPEN FOLD SEND EXEC KILL PLAN) to access per-OP typed fields. Full API: SPEC.md §12.
parsePath(raw) is a top-level helper that decomposes a path/URI string into a ParsedPath (the same decomposition applied to (target) slots). Reach for it to resolve a COPY destination: COPY's body is an opaque string — a destination URI for an entry copy, a prompt for a run fork (run://) — so the consumer interprets it by scheme and calls parsePath for the destination case. (MOVE destinations arrive pre-parsed; COPY's don't, because its body is polymorphic.)
cli
plurnk [file] parse to JSON; file or stdin
plurnk --helpExit 0 on clean parse, 1 on any error or unparsed tail.
syntax
<<OPsuffix [signal]? (path)? <L>? : body? :OPsuffix| slot | shape |
|----------|----------------------------------------------------|
| OP | FIND READ EDIT COPY MOVE OPEN FOLD SEND EXEC KILL PLAN |
| suffix | [A-Za-z0-9_]* glued to OP; used for nesting |
| […] | optional CSV; per-OP semantics |
| (…) | optional URI |
| <L> | optional <N>, <N,M>/<N-M>, or N-component <0.7,10,20>; components ∈ signed numbers — decimals mean insert-between (lines) or score threshold (results); parses to marks: number[] |
| :body: | optional; opaque between fences |
| OP | signal | body | line marker | |------|------------------|-----------------------|--------------------| | FIND | tag filter | matcher | result-set range | | READ | tag filter | matcher | per-entry lines | | EDIT | tags | content (empty=clear) | entry lines | | COPY | tags-to-apply | destination URI / fork prompt | entry lines | | MOVE | tags-to-apply | destination URI | entry lines | | OPEN | tag filter | matcher | result-set range | | FOLD | tag filter | matcher | result-set range | | SEND | HTTP status int | payload (JSON conv.) | n/a | | EXEC | executor | command or code | n/a | | KILL | unix signal int | annotation (opaque) | n/a | | PLAN | tags | reasoning text | n/a |
Matcher body dialect by leading char: // xpath · #…#flags regex · $ jsonpath · ~ semantic · @ graph · else glob. A body that fails its prefix-indicated dialect falls back to glob.
Path scheme detection: a leading # → path-name regex (#pattern#flags); else [a-z][a-z0-9+.-]*:// → URL (fully decomposed); else local (raw). Bare paths default to file:// at runtime.
Nesting: outer body may contain inner <<OP:…:OP statements; outer must use a non-empty suffix so its close :OPsuffix is distinct.
examples
List all xml files containing the admin user role. <<FIND(config/**/*.xml)://user[@role='admin']:FIND
Read hello in every language <<READ(lang/??.json):$.greeting:READ
Write a known entry <<EDITphilosophy,existentialism:The meaning of life is 42:EDIT
Read an entry in full <<READ(https://www.britannica.com/biography/Donald-Rumsfeld)::READ
Read lines 426–465 of a long article <<READ(https://en.wikipedia.org/wiki/Donald_Rumsfeld)<426-465>::READ
Create an unknown entry with tags <<EDITfrance,geography:What is the capital of France?:EDIT
Create a multi-line plan <<EDITplan,france,task:
- [ ] Decompose prompt into unknowns
- [ ] Discover capital of France
- [ ] Deliver :EDIT
Mark a plan step complete (single-line replace) <<EDIT(known:///plan)<2>:- [x] Discover capital of France:EDIT
Replace a range of lines <<EDIT(known:///countries/france/capital)<4-5>: The capital of France is Paris, on the river Seine. Paris has been the continuous capital of France since 987 CE. :EDIT
Append content to an existing entry <<EDIT(known:///countries/france/capital)<-1>:Wikipedia: Paris:EDIT
Prepend content to an existing entry <<EDIT(known:///countries/france/capital)<0>:Wikipedia: Paris:EDIT
Clear entry contents (empty body between two colons) <<EDIT(known:///countries/france/capital)::EDIT
Collapse every distilled fetch-log row <<FOLD(log:///1///get)::FOLD
Restore collapsed log rows by tag filter <<OPENfrance::OPEN
Rename a draft entry <<MOVE(known:///draft):known:///final/answer:MOVE
Run a shell command in the project root <<EXEC(./):node --test:EXEC
Continue the loop <<SEND[102]:decomposed prompt; plan initialized:SEND
Deliver the final answer <<SEND[200]:Paris:SEND
Search logs for budget-overflow errors (case-insensitive regex body) <<FIND(log:///**/error):#budget overflow|budget exceeded#i:FIND
Find entries whose content begins with "Paris" (glob body) <<FIND(known:///countries/**):Paris*:FIND
List the first 20 entries under a broad path (result-set pagination) <<FIND(known:///**)<1-20>::FIND
Read the first five lines of a local file (bare path → file://) <<READ(./README.md)<1-5>::READ
Copy a draft entry to a dated archive location <<COPY(known:///draft):known:///archive/2026-05-14/draft:COPY
Run an inline node script <<EXECnode: const sum = [1, 2, 3].reduce((a, b) => a + b, 0); console.log(sum); :EXEC
Restore log rows tagged france whose content matches (combined filters) <<OPENfrance:Paris*:OPEN
Collapse the second hundred of stale fetch-log rows (pagination) <<FOLD(log:///**/get)<101-200>::FOLD
Deliver a structured answer (JSON body) <<SEND[200]:{"answer":"Paris","confidence":0.95}:SEND
Report a client error (JSON body the model can traverse with jsonpath) <<SEND[400]:{"reason":"unrecognized OP","got":"FOOBAR","expected":["FIND","READ","EDIT","COPY","MOVE","OPEN","FOLD","SEND","EXEC","KILL","PLAN"]}:SEND
Report a server error with explicit recipient <<SEND503:{"reason":"git unavailable","command":"git status"}:SEND
Direct an informational message at a named agent <<SEND(agent://supervisor):decomposition complete; awaiting clearance:SEND
Kill a runaway process <<KILL(sh:///3/1/2)::KILL
Permanently delete an entry <<KILL(known:///obsolete/note)::KILL
Think aloud — reasoning recorded to the log <<PLAN:Need the capital fact; discover via wiki, record to known, deliver.:PLAN
Insert a line between lines 2 and 3 (decimal = between; replaces nothing) <<EDIT(known:///plan)<2.5>:- [ ] Verify against a second source:EDIT
Semantic search with a similarity threshold (decimal = minimum score) <<FIND(known:///**)<0.7>:~territorial concessions:FIND
Quote a plurnk operation inside another (nesting via suffix discipline) <<EDITouter(known:///demo): The following is a quoted plurnk operation, preserved verbatim: <<EDIT(known:///inner):hello world:EDIT :EDITouter
Find every entry whose path matches a regex (path-name regex target) <<FIND(#draft.*#i)::FIND
error format
Errors are JSON-serializable. Shape: { line, column, source, message } where source ∈ lexer | parser | visitor. Messages use protocol vocabulary (unrecognized character '<<' in path, expected close tag; got end of input).
gbnf
One generated GBNF grammar ships for llama.cpp constrained sampling, calibrated for the Fireworks/DeepSeek backend:
plurnk.gbnf(PLAN-anchored turn) —root ::= preplan plan sep batch-step* send-final-any sep. A Plurnk turn is a*:PLAN:OPS:SEND[N]sandwich: a FREE op-free reasoning preamble, then a MANDATORY<<PLANanchoring a strict ops-only batch (boundedWS{0,7}separators, no free prose between), closed by exactly one terminalSEND[102|200|300|499]— structural termination (forced EOS), not an optional stop a near-greedy decoder can sail past.
The preamble names no reasoning delimiter — it admits any text completing no <<OP opener. That keeps it format-agnostic across models and never masks a model's native reasoning token: the model reasons freely (a reasoning model's CoT separates into reasoning_content; a non-reasoning model reasons in the <<PLAN body, the public statement of intent), then <<PLAN anchors the strict turn. The ANTLR grammar enforces the same sandwich: PlurnkParser.parse(input) parses a turn — free text before PLAN, a required PLAN, nothing but whitespace between/after ops, and a required terminal SEND; a packet without a PLAN and a terminal SEND is invalid. A Plurnk packet IS a turn — there is no permissive fallback. PlurnkParser.parseStatements(input) parses a bare statement sequence (teaching-example collections, single ops); it is not for model output.
import.meta.resolve("@plurnk/plurnk-grammar/plurnk.gbnf")npm run test:llama validates the grammar against a live llama-server (PLURNK_LLAMA_URL, default http://127.0.0.1:11435) and demos constrained emission end-to-end. Opt-in; not part of test:all.
spec
SPEC.md — full grammar specification: canonical form, per-OP semantics, matcher dialects, path decomposition, error model, whitespace rules, implementation notes.
ecosystem
The @plurnk/* ecosystem pins peer versions exactly — no caret, no tilde, no ranges:
"@plurnk/plurnk-grammar": "0.23.0"Greenfield, single-orchestrator-per-repo, closed ecosystem. Determinism beats flexibility at this stage: when versions drift, the npm install error tells you which package needs a release. Silent semver wiggling masks coordination gaps that surface as mystery failures later.
Every grammar release cascades: every consuming package (plurnk-providers, plurnk-schemes, plurnk-execs, plurnk-mimetypes, plurnk-service, ...) bumps its pin and publishes a patch, then top-level consumers (plurnk-service, plurnk) bump theirs. Skipping a step = broken install downstream.
Not permanent — at v1 stabilization the policy widens back to semver ranges.
license
MIT.
