# 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

```ts
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](SPEC.md#12-public-api).

`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
	<<EDIT[philosophy,existentialism](worker:///philosophy/existentialism/meaning):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
	<<EDIT[france,geography](worker:///countries/france/capital):What is the capital of France?:EDIT

7. Create a multi-line plan
	<<EDIT[plan,france,task](worker://~/plan):
	- [ ] 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](https://en.wikipedia.org/wiki/Paris):EDIT

11. Prepend content to an existing entry
	<<EDIT(worker:///countries/france/capital)<0>:[Wikipedia: Paris](https://en.wikipedia.org/wiki/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
	<<OPEN[france](log:///**)::OPEN

15. Rename a draft entry
	<<MOVE(worker:///draft):worker:///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]:Paris: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):worker:///archive/2026-05-14/draft:COPY

24. Run an inline node script
	<<EXEC[node](./):
	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)
	<<OPEN[france](log:///**):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
	<<SEND[503](log:///errors):{"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 `source` ∈ `lexer | parser | visitor`. Messages use protocol vocabulary (`unrecognized character '<<' in path`, `expected close tag; got end of input`).

## gbnf

One generated [GBNF](https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md) 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.

```ts
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](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:

```json
"@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.
