npm.io
1.0.9 • Published yesterday

@plurnk/plurnk-grammar

Licence
MIT
Version
1.0.9
Deps
0
Vulns
0
Weekly
0

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

  1. List all xml files containing the admin user role. <<FIND(config/**/*.xml)://user[@role='admin']:FIND

  2. Read hello in every language <<READ(lang/??.json):$.greeting:READ

  3. Write a known entry <<EDITphilosophy,existentialism:The meaning of life is 42:EDIT

  4. Read an entry in full <<READ(https://www.britannica.com/biography/Donald-Rumsfeld)::READ

  5. Read lines 426–465 of a long article <<READ(https://en.wikipedia.org/wiki/Donald_Rumsfeld)<426-465>::READ

  6. Create an unknown entry with tags <<EDITfrance,geography:What is the capital of France?:EDIT

  7. Create a multi-line plan <<EDITplan,france,task:

    • Decompose prompt into unknowns
    • Discover capital of France
    • Deliver :EDIT
  8. Mark a plan step complete (single-line replace) <<EDIT(worker://~/plan)<2>:- [x] Discover capital of France:EDIT

  9. 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

  10. Append content to an existing entry <<EDIT(worker:///countries/france/capital)<-1>:Wikipedia: Paris:EDIT

  11. Prepend content to an existing entry <<EDIT(worker:///countries/france/capital)<0>:Wikipedia: Paris:EDIT

  12. Clear entry contents (empty body between two colons) <<EDIT(worker:///countries/france/capital)::EDIT

  13. Collapse every distilled fetch-log row <<FOLD(log:///1///get)::FOLD

  14. Restore collapsed log rows by tag filter <<OPENfrance::OPEN

  15. Rename a draft entry <<MOVE(worker:///draft)///final/answer:MOVE

  16. Run a shell command in the project root <<EXEC(./):node --test:EXEC

  17. Continue the loop <<SEND[102]:decomposed prompt; plan initialized:SEND

  18. Deliver the final answer <<SEND[200]SEND

  19. Search logs for budget-overflow errors (case-insensitive regex body) <<FIND(log:///**/error):#budget overflow|budget exceeded#i:FIND

  20. Find entries whose content begins with "Paris" (glob body) <<FIND(worker:///countries/**):Paris*:FIND

  21. List the first 20 entries under a broad path (result-set pagination) <<FIND(worker:///**)<1-20>::FIND

  22. Read the first five lines of a local file (bare path → file://) <<READ(./README.md)<1-5>::READ

  23. Copy a draft entry to a dated archive location <<COPY(worker:///draft)///archive/2026-05-14/draft:COPY

  24. Run an inline node script <<EXECnode: const sum = [1, 2, 3].reduce((a, b) => a + b, 0); console.log(sum); :EXEC

  25. Restore log rows tagged france whose content matches (combined filters) <<OPENfrance:Paris*:OPEN

  26. Collapse the second hundred of stale fetch-log rows (pagination) <<FOLD(log:///**/get)<101-200>::FOLD

  27. Deliver a structured answer (JSON body) <<SEND[200]:{"answer":"Paris","confidence":0.95}:SEND

  28. 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

  29. Report a server error with explicit recipient <<SEND503:{"reason":"git unavailable","command":"git status"}:SEND

  30. Direct an informational message at a named agent <<SEND(agent://supervisor):decomposition complete; awaiting clearance:SEND

  31. Kill a runaway process <<KILL(sh:///3/1/2)::KILL

  32. Permanently delete an entry <<KILL(worker:///obsolete/note)::KILL

  33. Think aloud — reasoning recorded to the log <<PLAN:Need the capital fact; discover via wiki, record to known, deliver.:PLAN

  34. Insert a line between lines 2 and 3 (decimal = between; replaces nothing) <<EDIT(worker://~/plan)<2.5>:- [ ] Verify against a second source:EDIT

  35. Semantic search with a similarity threshold (decimal = minimum score) <<FIND(worker:///**)<0.7>:~territorial concessions:FIND

  36. 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

  37. 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 sourcelexer | 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 <<PLAN anchoring a strict ops-only batch (bounded WS{0,7} separators, no free prose between), closed by exactly one terminal SEND[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.