summaryrefslogtreecommitdiffhomepage
path: root/packages/system-prompt/src/parser.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-23 23:04:30 +0900
committerAdam Malczewski <[email protected]>2026-06-23 23:04:30 +0900
commit674853d87d54dba1cd83c4e51fce5411602f4d5d (patch)
tree07455f9753a09a5ca66f8cb885a37ba3c0cb7787 /packages/system-prompt/src/parser.ts
parent4158e699e3c8ff556684fe2fc7a39ffab040623e (diff)
downloaddispatch-674853d87d54dba1cd83c4e51fce5411602f4d5d.tar.gz
dispatch-674853d87d54dba1cd83c4e51fce5411602f4d5d.zip
feat(system-prompt): template-based system prompt builder extension
New @dispatch/system-prompt extension (standard tier): - Pure parser: [type:name] variables, [if]/[else]/[endif] conditionals, negated [if !...], nested blocks, unmatched-tag pass-through. - Variable resolver (injected adapters): system:time/date/os/hostname, prompt:cwd/model/conversation_id, git:branch/status, file:<path> (dynamic). - Service handle: construct (resolve+persist) + get (cached, cache-safe). - Default template: persona + AGENTS.md if exists + cwd. - 52 tests (parser 29, resolver 12, catalog 3, service 8). transport-contract 0.17.0→0.18.0: SystemPromptTemplateResponse, SetSystemPromptTemplateRequest, SystemPromptVariable, SystemPromptVariablesResponse. Design: notes/system-prompt-design.md (caching constraint, compaction integration, wave plan). 1384 vitest pass.
Diffstat (limited to 'packages/system-prompt/src/parser.ts')
-rw-r--r--packages/system-prompt/src/parser.ts281
1 files changed, 281 insertions, 0 deletions
diff --git a/packages/system-prompt/src/parser.ts b/packages/system-prompt/src/parser.ts
new file mode 100644
index 0000000..5b39b7f
--- /dev/null
+++ b/packages/system-prompt/src/parser.ts
@@ -0,0 +1,281 @@
+/**
+ * Pure template parser — input → output, zero I/O.
+ *
+ * Handles variable insertion (`[type:name]`) and conditional blocks
+ * (`[if]`/`[else]`/`[endif]`, including negated `[if !...]` and nesting).
+ * Unmatched control tags are emitted as literal text (the tag passes through
+ * unchanged; surrounding content is parsed normally).
+ *
+ * Variable resolution rules (driven by the `vars` map):
+ * - value is a string → insert it
+ * - value is `null` (not existing) → insert empty string
+ * - key absent from the map (unknown type) → insert empty string
+ *
+ * Conditional existence: a variable "exists" iff it is present in the map AND
+ * its value is a string (not `null`). A `null` value or an absent key both mean
+ * "does not exist".
+ */
+
+// ─── Token model ─────────────────────────────────────────────────────────────
+
+interface TextToken {
+ readonly kind: "text";
+ readonly value: string;
+}
+interface VarToken {
+ readonly kind: "var";
+ readonly key: string;
+ readonly raw: string;
+}
+interface IfToken {
+ readonly kind: "if";
+ readonly key: string;
+ readonly negated: boolean;
+ readonly raw: string;
+}
+interface ElseToken {
+ readonly kind: "else";
+ readonly raw: string;
+}
+interface EndifToken {
+ readonly kind: "endif";
+ readonly raw: string;
+}
+type Token = TextToken | VarToken | IfToken | ElseToken | EndifToken;
+
+// ─── Node model (AST) ────────────────────────────────────────────────────────
+
+interface TextNode {
+ readonly kind: "text";
+ readonly value: string;
+}
+interface VarNode {
+ readonly kind: "var";
+ readonly key: string;
+}
+interface IfNode {
+ kind: "if";
+ key: string;
+ negated: boolean;
+ thenBranch: Node[];
+ else: Node[] | null;
+ matched: boolean;
+ readonly raw: string;
+}
+type Node = TextNode | VarNode | IfNode;
+
+// ─── Tokenizer ───────────────────────────────────────────────────────────────
+
+/**
+ * Classify the content between `[` and `]` into a control/variable token, or
+ * `null` when it is not a recognized tag (then it stays literal text).
+ */
+function classifyTag(content: string): Token | null {
+ const trimmed = content.trim();
+ if (trimmed === "endif") return { kind: "endif", raw: `[${content}]` };
+ if (trimmed === "else") return { kind: "else", raw: `[${content}]` };
+
+ // `[if type:name]` / `[if !type:name]`
+ const ifMatch = /^if\s+(!?)(\w+:.*)$/.exec(trimmed);
+ if (ifMatch) {
+ const negated = (ifMatch[1] ?? "") === "!";
+ const key = ifMatch[2] ?? "";
+ return { kind: "if", key, negated, raw: `[${content}]` };
+ }
+
+ // `[type:name]` — variable insertion (any `word:rest`)
+ const varMatch = /^(\w+:.*)$/.exec(trimmed);
+ if (varMatch) return { kind: "var", key: trimmed, raw: `[${content}]` };
+
+ return null;
+}
+
+/**
+ * Split a template into a flat token stream: text, variable, and control tags.
+ * A `[` with no closing `]`, or whose content is not a recognized tag, is kept
+ * as literal text.
+ */
+function tokenize(template: string): Token[] {
+ const tokens: Token[] = [];
+ let buf = "";
+ let i = 0;
+ const n = template.length;
+
+ const flush = (): void => {
+ if (buf.length > 0) {
+ tokens.push({ kind: "text", value: buf });
+ buf = "";
+ }
+ };
+
+ while (i < n) {
+ const ch = template[i];
+ if (ch === undefined) break;
+ if (ch === "[") {
+ const close = template.indexOf("]", i + 1);
+ if (close === -1) {
+ buf += "[";
+ i++;
+ continue;
+ }
+ const content = template.slice(i + 1, close);
+ const tag = classifyTag(content);
+ if (tag !== null) {
+ flush();
+ tokens.push(tag);
+ i = close + 1;
+ continue;
+ }
+ buf += "[";
+ i++;
+ } else {
+ buf += ch;
+ i++;
+ }
+ }
+ flush();
+ return tokens;
+}
+
+// ─── Parser (token stream → AST) ─────────────────────────────────────────────
+
+const EMPTY: readonly Node[] = Object.freeze([]) as readonly Node[];
+
+/**
+ * Build an AST from tokens using an explicit stack. `if`/`else`/`endif` are
+ * matched here: an `if` left open at end-of-input is marked unmatched, and a
+ * stray `else`/`endif` (no open `if`) becomes a literal text node.
+ */
+function parse(tokens: readonly Token[]): Node[] {
+ const root: Node[] = [];
+ const stack: IfNode[] = [];
+ let current: Node[] = root;
+
+ for (const tok of tokens) {
+ switch (tok.kind) {
+ case "text":
+ current.push({ kind: "text", value: tok.value });
+ break;
+ case "var":
+ current.push({ kind: "var", key: tok.key });
+ break;
+ case "if": {
+ const node: IfNode = {
+ kind: "if",
+ key: tok.key,
+ negated: tok.negated,
+ thenBranch: [],
+ else: null,
+ matched: true,
+ raw: tok.raw,
+ };
+ current.push(node);
+ stack.push(node);
+ current = node.thenBranch;
+ break;
+ }
+ case "else": {
+ const top = stack[stack.length - 1];
+ if (top !== undefined && top.else === null) {
+ top.else = [];
+ current = top.else;
+ } else {
+ // stray else (no open if, or if already has an else) → literal
+ current.push({ kind: "text", value: tok.raw });
+ }
+ break;
+ }
+ case "endif": {
+ const top = stack.pop();
+ if (top === undefined) {
+ // stray endif → literal
+ current.push({ kind: "text", value: tok.raw });
+ break;
+ }
+ const parent = stack[stack.length - 1];
+ current = parent === undefined ? root : (parent.else ?? parent.thenBranch);
+ break;
+ }
+ }
+ }
+
+ // Any `if` still on the stack never found its `endif` → unmatched.
+ for (const node of stack) node.matched = false;
+ return root;
+}
+
+// ─── Renderer (AST → string) ────────────────────────────────────────────────
+
+function variableExists(key: string, vars: ReadonlyMap<string, string | null>): boolean {
+ return vars.has(key) && vars.get(key) !== null;
+}
+
+function render(nodes: readonly Node[], vars: ReadonlyMap<string, string | null>): string {
+ let out = "";
+ for (const node of nodes) {
+ switch (node.kind) {
+ case "text":
+ out += node.value;
+ break;
+ case "var":
+ out += vars.get(node.key) ?? "";
+ break;
+ case "if": {
+ if (node.matched) {
+ const exists = variableExists(node.key, vars);
+ const takeThen = node.negated ? !exists : exists;
+ const branch = takeThen ? node.thenBranch : (node.else ?? EMPTY);
+ out += render(branch, vars);
+ } else {
+ // Unmatched `if` → the tag is literal text; content still renders.
+ out += node.raw;
+ out += render(node.thenBranch, vars);
+ if (node.else !== null) {
+ out += "[else]";
+ out += render(node.else, vars);
+ }
+ }
+ break;
+ }
+ }
+ }
+ return out;
+}
+
+// ─── Public API ──────────────────────────────────────────────────────────────
+
+/**
+ * Resolve a template against a variable map → the final string.
+ *
+ * - `[type:name]` → the resolved value, or empty string when absent/null.
+ * - `[if type:name] ... [endif]` → renders when the variable exists (string).
+ * - `[if !type:name]` → renders when it does NOT exist.
+ * - `[else]` provides the fallback branch.
+ * - Unmatched `[if]`/`[endif]` tags pass through as literal text.
+ */
+export function parseTemplate(template: string, vars: ReadonlyMap<string, string | null>): string {
+ const tokens = tokenize(template);
+ const ast = parse(tokens);
+ return render(ast, vars);
+}
+
+/**
+ * Collect every variable key referenced by a template — from both insertion
+ * (`[type:name]`) and conditionals (`[if type:name]` / `[if !type:name]`).
+ * Used by the resolver to know which dynamic `file:<path>` variables to read.
+ * Returns unique keys in first-seen order.
+ */
+export function extractVariables(template: string): string[] {
+ const tokens = tokenize(template);
+ const seen = new Set<string>();
+ const keys: string[] = [];
+ for (const tok of tokens) {
+ if (tok.kind === "var" || tok.kind === "if") {
+ if (!seen.has(tok.key)) {
+ seen.add(tok.key);
+ keys.push(tok.key);
+ }
+ }
+ }
+ return keys;
+}