@plurnk/plurnk-grammar
plurnk-grammar
Parser for the Plurnk protocol — a HEREDOC-style DSL for LLM agents.
install
npm install @plurnk/plurnk-grammar
Requires 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 worker fork (worker://) — 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 --help
Exit 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(worker://~/plan)<2>:- [x] Discover capital of France:EDIT
Replace a range of lines <<EDIT(worker:///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(worker:///countries/france/capital)<-1>:Wikipedia: Paris:EDIT
Prepend content to an existing entry <<EDIT(worker:///countries/france/capital)<0>:Wikipedia: Paris:EDIT
Clear entry contents (empty body between two colons) <<EDIT(worker:///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(worker:///draft)///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]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(worker:///countries/**):Paris*:FIND
List the first 20 entries under a broad path (result-set pagination) <<FIND(worker:///**)<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(worker:///draft)///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(worker:///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(worker://~/plan)<2.5>:- [ ] Verify against a second source:EDIT
Semantic search with a similarity threshold (decimal = minimum score) <<FIND(worker:///**)<0.7>:~territorial concessions:FIND
Quote a plurnk operation inside another (nesting via suffix discipline) <<EDITouter(worker:///demo): The following is a quoted plurnk operation, preserved verbatim: <<EDIT(worker:///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.