Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

pi-template

A pi extension for context templating. Any text part of the system prompt — the global and project AGENTS.md (including the cwd ancestor chain), SYSTEM.md/APPEND_SYSTEM.md, skill descriptions and bodies, prompts, agent files — can branch on variables whose values come from the config, the environment, or a shell command.

How it works

  • A source is an X.template file, the only file edited by hand. A target is the rendered copy X next to the source. Sources are the files pi consumes natively (AGENTS.md, SKILL.md, *.md prompts, SYSTEM.md, APPEND_SYSTEM.md) and include sources — X.template files a template mentions as a partial; the target of an include source is the rendered copy to read, pi never loads it. None of pi's mechanisms sees .template files.
  • At start a session computes the view — the full set of variable values — and renders the targets. The current context version is kept in memory and delivered into the prompt by pointwise section replacement; the targets are the last-success cache.
  • Foreign reads of targets (the read tool for skill bodies, pi-subagents for agent files) run under a file lock: the extension re-renders the target under its session's view, the reader reads, the lock is released.
  • A file render failure does not rewrite the target and does not let raw tags into the prompt: the consumer gets the previous successful text, and the reason goes to the UI as a warning.

Example usage: implement plan mode in Pi

A mode is how the agent works in a session — planning, developing, reviewing — and the mode text is templated: a variable picks which text enters AGENTS.md (rules, tone, delegation policy). A mode is not an engine concept: it is an ordinary variable plus a dynamic partial.

The reference setup — modes live in modes/, one source per mode; the agent directory:

~/.pi/agent/
├── template.json
├── AGENTS.md.template
└── modes/
    ├── plan.md.template
    └── code.md.template

template.json — the variable whose values are the mode files:

{
  "vars": {
    "mode": {
      "values": { "shell": "basename -s .md.template modes/*.md.template" },
      "initial": "plan"
    }
  },
  "statusVariable": "mode"
}

AGENTS.md.template — the selected mode is included as a dynamic partial:

{{> (concat "modes/" mode ".md")}}

The partial name is the plain name (modes/plan.md), resolved on disk at render time; the text is read from the source modes/plan.md.template when it is present, otherwise from modes/plan.md. Adding a mode is dropping a file into modes/. Every mentioned X.template — mode files included — is an include source: its target X is written next to it, and a plain X without a source is hand-written and never overwritten.

Installation

The package installs as a pi git package: in the agent directory settings.json

{ "packages": ["git:github.com/NikolayXHD/pi-template"] }

The entry point is declared in package.json (pi.extensions). The dependency is handlebars (make deps or npm install).

template.json config

It lives in the agent directory (~/.pi/agent/template.json) and declares variables:

{
  "vars": {
    "vllm": {
      "values": ["on", "off"],
      "initial": "on"
    },
    "git_branch": {
      "initial": { "shell": "git rev-parse --abbrev-ref HEAD" },
      "cwd": "project"
    }
  },
  "statusVariable": "vllm"
}

The top-level statusVariable names the variable the status line shows (template ✓ vllm=on); it must name a declared variable with a non-empty value list — otherwise the config is an error. Without the field the status line is just the state glyph.

Variable fields:

  • values — a static list of strings or {"shell": "…"}: the values are stdout lines, one per line (trim, empty lines ignored, duplicates collapsed). A non-zero exit or a timeout (5 s) gives a warning and leaves the variable without a list. The list feeds lint: condition literals are checked against it, dynamic partial names are resolved on disk.
  • initial — the machine value: a string, {"shell": "…"} or {"env": "VAR"} (a lookup with no command run). A computed initial makes the variable read-only — a session override is forbidden for it.
  • compute — a pure derivation from other variables, evaluated in the process (no shell) and re-derived whenever the view changes. The expression is a value: a variable name (its value), concat of literals and variables, or a condition (eq/in, and/or/not) as the strings "true"/"false". A boolean expression gives the implied values list ["true", "false"]; a string one has no list. Not combined with initial or values. The variable is overridable — a session override wins over the expression; for a boolean variable an override or an @ value outside true/false is an error. Expression references must name variables declared earlier (or the implicit model/level) — an unknown or later name is a config error; and, or, not, eq, in are reserved names.
  • @ and @<agent name> — the variable value at a subagent; @<name> wins over @. They replace both the machine and the computed value; without either key a subagent sees the machine value (a computed one sees its computation). Strings only.
  • cwd — the command working directory: "agent" (default, the agent directory) or "project" (the session cwd; children use their own).

Config rules:

  • variable names are identifiers; the name is the env variable name, and a collision with the process environment is an error;
  • model and level are implicit: declared by pi itself, read-only for the config; declaring them is an error;
  • an unknown key is an error; a broken template.json means the view is not built and there is no re-render, with a warning naming the reason;
  • commands run in the platform system shell; string substitution of values into the command text is forbidden: the command receives variable values through the environment;
  • computation follows declaration order; a variable is never computed twice; compute expressions see the variables declared earlier.

Value priority: a session override wins over initial and over compute; shell/env computed variables have no override.

Template syntax

The vocabulary is deliberately narrow; everything outside it is a lint error, not a silent rejection:

  • {{#if (eq mode "plan")}}…{{else}}…{{/if}}, {{#if (in vllm "on" "auto")}}…{{/if}}, and the same with {{#unless}}; conditions compose — and, or, not over comparisons and boolean variables: {{#if (or is_flash (eq level "off"))}}…{{/if}};
  • a bare name as a condition — a boolean variable (values true/false, a compute expression): {{#if is_flash}}…{{/if}}. Truth is the exact string "true", anything else — including "false" — is false;
  • interpolation {{mode}} — declared variables and the implicit {{model}}/{{level}} with the model address parts {{model_provider}}/{{model_id}};
  • partials: {{> roles/snippets/tests.md}} — the name is a path from the root of its directory (agent ones from the agent directory, project ones from the project root with the .pi/ prefix). The name is resolved on disk at render time: the text comes from X.template when present, otherwise from X. A mentioned X.template is an include source — its target X carries the rendered text (nested includes expanded); a .template that no template mentions has no target, and the rewrite report names it. There is no pre-scan of directories, so an include from an arbitrary directory ({{> modes/plan.md}}) resolves like any other; a name that escapes its root is not resolved. A missing static name is a lint error; a missing value of a dynamic partial is a warning, and a selected value without a file fails the render naming the value. The include text is linted when it is read (an @ qualifier is legal only in agent files). Names with a leading dot are written as string literals: {{> ".pi/skills/x/SKILL.md"}}; a dynamic partial is {{> (concat "modes/" mode ".md")}}, concat of literal segments and variables, the name is checked for every value in the list;
  • part helpers: {{frontmatterKeys "x"}} and {{bodyAfterFrontmatter "x"}} — the frontmatter keys of a foreign file (the lines inside the --- wrapper) or its body, as is. The wrapper is written by the template, so a foreign agent file is re-used with your own keys (a timeoutMs line) added. The name argument is a string literal, a variable or concat — the partial name vocabulary; a bare path is not a name (an argument resolves as an expression). The cut is strict: the first line is --- up to a BOM and trailing spaces, the closing line is --- up to trailing spaces — otherwise no frontmatter / unclosed frontmatter. A missing static name is a lint error, a missing dynamic one is a warning and a render error for the selected value (as partials). The part text is not a template — not rendered, not linted; a raw {{ in it is a warning, and such a target legitimately contains raw tags;
  • comments {{! … }}; a literal {{ in text is escaped as \{{;
  • frontmatter is rendered alongside the body; a rendered file with frontmatter is validated by YAML parsing — skill descriptions and agent headers live there.

Part helpers in agents/reviewer.md.template — the agent file of a package with your own timeoutMs in frontmatter:

---
{{frontmatterKeys "npm/node_modules/pi-subagents/agents/reviewer.md"}}
timeoutMs: 3600000
---
{{bodyAfterFrontmatter "npm/node_modules/pi-subagents/agents/reviewer.md"}}

The @ qualifier references a variable value at a subagent: {{[vllm@]}} (any subagent) and {{[vllm@foo]}} (agent foo). It is legal only in agent files (agents/**); in the file of agent foo only @ and @foo are allowed.

Two-stage agent file render. The child's model and level are determined by the header of the same file, so the header is rendered iterated to a fixpoint (preliminary model/level → YAML of the header → actual values), and the body with the full future child view: model@/level@ get actual values, custom @ variables get the subagent values from the config. A model or level expression that does not converge is a render error ("header model depends on model@").

The /template command

The extension's only command opens the context management menu:

  • session context status as one glyph on the menu title: ✓ applied (the prompt equals a fresh render), ! a re-render is waiting (a source was edited, or a variable change altered the render), ‼ no view built (a broken config);
  • rows of the variables: the value; for shell-computed ones the value age and recompute on Enter (a compute derivation is an ordinary row — overridable, without an age); for overridden ones "session override"; the variable status as the last segment — a colored glyph: ✓ green (the value is in the applied context) or ! yellow (it differs — the context has not been applied since); implicit model/level are rows of the list as well — read-only, they change during a session (model switch, thinking level), so their status matters like any other's;
  • "recompute all variables";
  • "rewrite files on disk" — writing the current version into the targets without applying it. The notification is the summary (how many written, unchanged, failed); the menu keeps the report of the last rewrite — one row per file: written, unchanged, failed with the reason, or not included — a .template no template mentions, so no target exists for it;
  • "update current session context" — applying the current version.

The file buttons follow the workflow: rewrite first, read the files if you like, then apply to the session context — the button or ctrl+alt+r. An action does not close the menu — the actions are granular and several in a row is the usual case; Esc does.

A variable change is recorded in the session (customType: "var", payload {name, value}) and does not update the context by itself — only the status changes. Resume picks up the last value of each variable from the records.

The quick switch cycles values without the menu: ctrl+r takes the next value of the status variable, alt+r takes the next variable of the ring — declared ones with a non-empty value list. A value change is a session override, exactly as a menu change: the context is not applied; alt+r writes its choice into template.json immediately. ctrl+alt+r applies the current version — the button action without the menu. In pi ctrl+r is the built-in "rename session" shortcut; the extension overrides it — pi reports the conflict on startup.

The status line shows one state glyph (✓ applied, ! a re-render is waiting, ‼ no view) and, when statusVariable is set, its pair: template ✓ vllm=on (a long value is compacted). The fact of a session override stays in the menu (session override).

Session context versions

The extension keeps two versions of the context files in memory: the applied one (in the prompt and in the targets) and the current one (re-rendered from fresh sources and values).

  • Editing a source or an included file, changing a session variable or the model/level re-renders only the current version and marks the context stale: the prompt itself never changes.
  • The applied version is fixed at the first prompt assembly: everything that changed since the session start (a variable, model/level, a source or config edit) enters the first prompt by itself — before it there is no conversation and no cache to lose. After it applying is explicit: the "update current session context" button or ctrl+alt+r. The start apply and the button deliver the text by pointwise section replacement; /reload re-reads the targets by pi itself (the extension writes the current version on session_shutdown). "Rewrite files on disk" writes the targets without applying anything.
  • Sources and the files of the last render's includes are compared by mtime+size on every turn; shell computations run only by explicit menu actions, compute expressions are re-derived on every view change (model/level, overrides). A template.json edit is picked up the same way — the config is re-read and the view rebuilt on the next turn or menu open; a broken file keeps the previous config with a warning.

Subagents

The mechanism requires templates to expand fully in child sessions too: the extension registers itself as required for them (registerRequiredChildExtensions). A child's variable values are set by @/@<name> keys; a child session does not inherit the parent's session overrides. Outside the pi process (machine/pane-native runs, external CLI agents) the extension does not work — the targets remain coherent last successful text.

Package resources (skills and prompts of git/npm packages) are not templated.

Checks

node probe.mjs — a scenario run of the engine and consumption points. Live runs against the pi SDK — live/*.mjs; scenario descriptions and manual checks — TESTING.md.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages