summaryrefslogtreecommitdiffhomepage
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
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.
-rw-r--r--bun.lock12
-rw-r--r--notes/system-prompt-design.md213
-rw-r--r--packages/system-prompt/package.json12
-rw-r--r--packages/system-prompt/src/catalog.test.ts31
-rw-r--r--packages/system-prompt/src/catalog.ts28
-rw-r--r--packages/system-prompt/src/extension.ts62
-rw-r--r--packages/system-prompt/src/index.ts16
-rw-r--r--packages/system-prompt/src/parser.test.ts186
-rw-r--r--packages/system-prompt/src/parser.ts281
-rw-r--r--packages/system-prompt/src/resolver.test.ts210
-rw-r--r--packages/system-prompt/src/resolver.ts139
-rw-r--r--packages/system-prompt/src/service.test.ts151
-rw-r--r--packages/system-prompt/src/service.ts67
-rw-r--r--packages/system-prompt/src/types.ts39
-rw-r--r--packages/system-prompt/tsconfig.json6
-rw-r--r--packages/transport-contract/package.json2
-rw-r--r--packages/transport-contract/src/index.ts51
-rw-r--r--tsconfig.json1
18 files changed, 1505 insertions, 2 deletions
diff --git a/bun.lock b/bun.lock
index 1dbb882..e6da398 100644
--- a/bun.lock
+++ b/bun.lock
@@ -189,6 +189,14 @@
"@dispatch/ui-contract": "workspace:*",
},
},
+ "packages/system-prompt": {
+ "name": "@dispatch/system-prompt",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/transport-contract": "workspace:*",
+ },
+ },
"packages/throughput-store": {
"name": "@dispatch/throughput-store",
"version": "0.0.0",
@@ -260,7 +268,7 @@
},
"packages/transport-contract": {
"name": "@dispatch/transport-contract",
- "version": "0.16.0",
+ "version": "0.18.0",
"dependencies": {
"@dispatch/ui-contract": "workspace:*",
"@dispatch/wire": "workspace:*",
@@ -357,6 +365,8 @@
"@dispatch/surface-registry": ["@dispatch/surface-registry@workspace:packages/surface-registry"],
+ "@dispatch/system-prompt": ["@dispatch/system-prompt@workspace:packages/system-prompt"],
+
"@dispatch/throughput-store": ["@dispatch/throughput-store@workspace:packages/throughput-store"],
"@dispatch/todo": ["@dispatch/todo@workspace:packages/todo"],
diff --git a/notes/system-prompt-design.md b/notes/system-prompt-design.md
new file mode 100644
index 0000000..32e13e0
--- /dev/null
+++ b/notes/system-prompt-design.md
@@ -0,0 +1,213 @@
+# System Prompt Builder — design + plan
+
+> Orchestrator-authored plan. Drives the `prompts/<unit>.md` TASK blocks.
+> User-approved: all variable types, `[else]`, negated `[if !...]`, caching, compaction append.
+
+## 1. Goal
+
+A **template-based system prompt builder** that lets the user define a system prompt
+with variable placeholders (`[type:name]`) and conditionals (`[if]`/`[else]`/`[endif]`).
+Variables are resolved at **construction time** (not every turn) to preserve the prompt
+cache.
+
+Hard requirements:
+- Template persisted globally; resolved system prompt persisted **per conversation**.
+- **Cache-safe:** constructed once (first turn), reused on all subsequent turns.
+ Reconstructed only on **compaction** (fresh variable resolution).
+- Compaction appends its instructions to the constructed system prompt.
+- API: GET/PUT template, GET available variables.
+- Frontend: full-page modal (text editor + variable selector buttons).
+
+## 2. Template format
+
+### Variable insertion
+```
+[type:name]
+```
+Resolves the variable at construction time. Unknown type → blank string.
+Non-existent variable (e.g. file not found) → blank string.
+
+### Conditional blocks
+```
+[if type:name]
+ ...content if variable exists...
+[else]
+ ...content if variable does NOT exist...
+[endif]
+```
+Negated condition:
+```
+[if !type:name]
+ ...content if variable does NOT exist...
+[endif]
+```
+- Nested `[if]` blocks: supported.
+- Multi-line content between tags: supported.
+- Unmatched `[if]`/`[endif]`: treated as literal text (not parsed).
+
+### Edge cases
+- Unknown variable type (e.g. `[unknown:foo]`) → blank string.
+- File read failure → variable is "not existing" → blank string; `[if]` → skipped.
+- Empty template → no system prompt sent (current behavior preserved).
+
+## 3. Variable types
+
+| Type:Name | Description | Source |
+|---|---|---|
+| `system:time` | Current time (ISO 8601) | `new Date().toISOString()` |
+| `system:date` | Current date (YYYY-MM-DD) | derived from `new Date()` |
+| `system:os` | Operating system | `process.platform` |
+| `system:hostname` | Machine hostname | `require("node:os").hostname()` |
+| `prompt:cwd` | Conversation's working directory | passed from session-orchestrator |
+| `prompt:model` | Current model name | passed from session-orchestrator |
+| `prompt:conversation_id` | Conversation ID | passed from session-orchestrator |
+| `git:branch` | Current git branch | `git rev-parse --abbrev-ref HEAD` in cwd |
+| `git:status` | Short git status | `git status --short` in cwd |
+| `file:<path>` | File contents (relative to cwd, or absolute if starts `/`) | `fs.readText` |
+
+The `file:` type is **dynamic** — any path is a valid variable name. All others have
+fixed names. The `git:` variables require `spawn` capability; failures (not a git repo,
+git not installed) → variable is "not existing" → blank string.
+
+## 4. Caching constraint (critical)
+
+**The system prompt is constructed ONCE (on the first turn of a conversation) and
+persisted.** Subsequent turns reuse the persisted system prompt — no reconstruction.
+This preserves the prompt cache (the system prompt is part of the cacheable prefix).
+
+**Reconstruction happens only on compaction** (the conversation is being summarized,
+so fresh variable resolution makes sense — files may have changed, cwd may have changed,
+the time is stale).
+
+Flow:
+1. **First turn** (new conversation): session-orchestrator detects `getConversationMeta
+ === null` → calls `systemPromptService.construct(conversationId, cwd, {model})` →
+ resolves all variables → persists the result → returns the string → sets on
+ `providerOpts.systemPrompt`.
+2. **Subsequent turns:** session-orchestrator calls
+ `systemPromptService.get(conversationId)` → returns the persisted string (or null if
+ never constructed) → sets on `providerOpts.systemPrompt` (or omits if null).
+3. **Compaction turn:** session-orchestrator calls
+ `systemPromptService.construct(conversationId, cwd, {model})` → gets fresh resolved
+ system prompt → appends `COMPACTION_SYSTEM_PROMPT` → uses the combined string as the
+ compaction turn's system prompt. The `construct` call also persists the reconstructed
+ system prompt (without compaction instructions) for future turns.
+4. **Post-compaction turns:** session-orchestrator calls
+ `systemPromptService.get(conversationId)` → returns the reconstructed system prompt.
+
+Changing the template (via `PUT /system-prompt`) does NOT affect existing conversations
+until they are compacted. New conversations use the new template on their first turn.
+
+## 5. Default template
+
+When no template has been set, a built-in default is used:
+```
+You are a helpful coding assistant.
+
+[if file:AGENTS.md]
+[file:AGENTS.md]
+[endif]
+
+The current working directory is [prompt:cwd].
+```
+
+## 6. Architecture
+
+### `system-prompt` extension (new, standard tier)
+
+**Manifest:** `id: "system-prompt"`, `capabilities: { fs: true, spawn: true }`,
+`dependsOn: []` (depends only on kernel contracts).
+
+**Pure parser** (`parser.ts`):
+- `parseTemplate(template: string, resolvedVars: Map<string, string | null>): string`
+- Handles `[type:name]` insertion, `[if]`/`[else]`/`[endif]` conditionals, nesting.
+- Pure: input → output, no I/O. Fully testable with zero mocks.
+
+**Variable resolver** (`resolver.ts`):
+- `resolveVariables(cwd: string, context?: { model?: string; conversationId?: string }):
+ Promise<Map<string, string | null>>`
+- Reads files (`fs` capability), runs git commands (`spawn` capability), gets system
+ info. Injected adapters (same pattern as LSP extension).
+- Returns `Map<string, string | null>` — `null` means "not existing" (file not found,
+ git not available, etc.).
+
+**Variable catalog** (`catalog.ts`):
+- `getVariableCatalog(): SystemPromptVariable[]` — the static list of available variables
+ (for `GET /system-prompt/variables`). The `file:` type is marked `dynamic: true`.
+
+**Service handle** (`types.ts`):
+```ts
+export interface SystemPromptService {
+ construct(conversationId: string, cwd: string, context?: {
+ readonly model?: string;
+ }): Promise<string>;
+ get(conversationId: string): Promise<string | null>;
+}
+```
+- `construct`: reads the template from storage, resolves all variables, persists the
+ result under `system-prompt:resolved:<conversationId>`, returns the string.
+- `get`: reads the persisted result (or null).
+
+**Storage:**
+- Template: `host.storage("system-prompt")` key `"template"` (global).
+- Resolved system prompt: `host.storage("system-prompt")` key
+ `"resolved:<conversationId>"` (per conversation).
+
+### Session-orchestrator integration
+
+The session-orchestrator wires `systemPromptService` as an **optional dep** (same pattern
+as `message-queue` / `toolsFilter` — lazily via `host.getService(systemPromptHandle)` in
+`activate`). If the system-prompt extension isn't loaded, no system prompt is sent
+(current behavior preserved).
+
+Changes in `orchestrator.ts`:
+1. **Turn flow (~line 461):** after resolving `effectiveCwd`, determine if this is the
+ first turn (new conversation) or a subsequent turn:
+ - First turn (new conversation, detected via `getConversationMeta === null` already
+ checked earlier for workspace assignment): call
+ `deps.resolveSystemPrompt?.construct(conversationId, effectiveCwd, { model: modelName })`.
+ - Subsequent turns: call `deps.resolveSystemPrompt?.get(conversationId)`.
+ - If the result is non-null, set it on `providerOpts.systemPrompt`.
+2. **Compaction flow (~line 911):** currently sets `systemPrompt: COMPACTION_SYSTEM_PROMPT`.
+ Change to: call `deps.resolveSystemPrompt?.construct(conversationId, cwd, { model })` →
+ get the constructed system prompt → append `"\n\n" + COMPACTION_SYSTEM_PROMPT` → set on
+ `providerOpts.systemPrompt`. If the service is unavailable, fall back to just
+ `COMPACTION_SYSTEM_PROMPT` (current behavior).
+
+### API endpoints (transport-http)
+
+| Method | Path | Description |
+|---|---|---|
+| GET | `/system-prompt` | Returns `{ template: string }` |
+| PUT | `/system-prompt` | Body `{ template: string }` → persists, returns `{ template: string }` |
+| GET | `/system-prompt/variables` | Returns `{ variables: SystemPromptVariable[] }` |
+
+The transport-http extension calls the `systemPromptHandle` service for these (same
+pattern as `lspServiceHandle` for the LSP status endpoint). If the service is unavailable,
+`GET` returns the default template, `PUT` returns 503, `GET /variables` returns the
+static catalog.
+
+## 7. Units & waves
+
+### Wave 0 (orchestrator, contracts)
+- `transport-contract`: add `SystemPromptTemplateResponse`,
+ `SetSystemPromptTemplateRequest`, `SystemPromptVariable`,
+ `SystemPromptVariablesResponse`. Version bump.
+
+### Wave 1
+- `system-prompt` (NEW extension): pure parser + variable resolver + catalog + storage
+ + service handle. Depends only on kernel contracts. Tests: parser (pure, zero mocks),
+ resolver (injected adapters), catalog.
+
+### Wave 2 (parallel — disjoint packages)
+- `session-orchestrator`: wire `systemPromptService` as optional dep; construct on first
+ turn, get on subsequent, construct+append on compaction. + tests.
+- `transport-http`: add `GET/PUT /system-prompt` + `GET /system-prompt/variables` routes.
+ + tests.
+
+### Wave 3
+- `host-bin`: register `system-prompt` in `CORE_EXTENSIONS`.
+- Orchestrator: root tsconfig ref + `bun install`.
+
+### Wave 4
+- Full-graph verify + FE courier handoff.
diff --git a/packages/system-prompt/package.json b/packages/system-prompt/package.json
new file mode 100644
index 0000000..70ce940
--- /dev/null
+++ b/packages/system-prompt/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@dispatch/system-prompt",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*",
+ "@dispatch/transport-contract": "workspace:*"
+ }
+}
diff --git a/packages/system-prompt/src/catalog.test.ts b/packages/system-prompt/src/catalog.test.ts
new file mode 100644
index 0000000..406455b
--- /dev/null
+++ b/packages/system-prompt/src/catalog.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { getVariableCatalog } from "./catalog.js";
+
+describe("catalog", () => {
+ it("lists all fixed variables", () => {
+ const catalog = getVariableCatalog();
+ const keys = catalog.map((v) => `${v.type}:${v.name}`);
+
+ expect(keys).toContain("system:time");
+ expect(keys).toContain("system:date");
+ expect(keys).toContain("system:os");
+ expect(keys).toContain("system:hostname");
+ expect(keys).toContain("prompt:cwd");
+ expect(keys).toContain("prompt:model");
+ expect(keys).toContain("prompt:conversation_id");
+ expect(keys).toContain("git:branch");
+ expect(keys).toContain("git:status");
+ });
+
+ it("marks the file type as dynamic", () => {
+ const fileVar = getVariableCatalog().find((v) => v.type === "file");
+ expect(fileVar).toBeDefined();
+ expect(fileVar?.dynamic).toBe(true);
+ });
+
+ it("every entry has a description", () => {
+ for (const v of getVariableCatalog()) {
+ expect(v.description.length).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/packages/system-prompt/src/catalog.ts b/packages/system-prompt/src/catalog.ts
new file mode 100644
index 0000000..cfa3bb1
--- /dev/null
+++ b/packages/system-prompt/src/catalog.ts
@@ -0,0 +1,28 @@
+/**
+ * Static catalog of available system-prompt variables.
+ *
+ * Returned by `GET /system-prompt/variables` so the frontend can render the
+ * variable selector buttons. The `file:` type is marked `dynamic: true` — any
+ * name (file path) is valid for it.
+ */
+import type { SystemPromptVariable } from "@dispatch/transport-contract";
+
+export function getVariableCatalog(): SystemPromptVariable[] {
+ return [
+ { type: "system", name: "time", description: "Current time in ISO 8601 format" },
+ { type: "system", name: "date", description: "Current date (YYYY-MM-DD)" },
+ { type: "system", name: "os", description: "Operating system platform" },
+ { type: "system", name: "hostname", description: "Machine hostname" },
+ { type: "prompt", name: "cwd", description: "Conversation working directory" },
+ { type: "prompt", name: "model", description: "Current model name" },
+ { type: "prompt", name: "conversation_id", description: "Conversation identifier" },
+ { type: "git", name: "branch", description: "Current git branch" },
+ { type: "git", name: "status", description: "Short git status" },
+ {
+ type: "file",
+ name: "<path>",
+ description: "Contents of a file (relative to cwd, or absolute if it starts with /)",
+ dynamic: true,
+ },
+ ];
+}
diff --git a/packages/system-prompt/src/extension.ts b/packages/system-prompt/src/extension.ts
new file mode 100644
index 0000000..7b6575d
--- /dev/null
+++ b/packages/system-prompt/src/extension.ts
@@ -0,0 +1,62 @@
+/**
+ * system-prompt extension — manifest + activate(host).
+ *
+ * Builds the service with real Bun-backed adapters (Bun.file for fs,
+ * Bun.spawn for git), persists the template + resolved prompts via a namespaced
+ * storage, and provides the service through `systemPromptHandle`.
+ */
+import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
+import type { GitSpawnResult, ResolverAdapters } from "./resolver.js";
+import { createSystemPromptService } from "./service.js";
+import { systemPromptHandle } from "./types.js";
+
+export const manifest: Manifest = {
+ id: "system-prompt",
+ name: "System Prompt",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ dependsOn: [],
+ capabilities: { fs: true, spawn: true },
+ contributes: { services: ["system-prompt"] },
+};
+
+/** Run a command and capture stdout/stderr (used for git). */
+async function realSpawn(
+ command: readonly string[],
+ opts: { readonly cwd: string },
+): Promise<GitSpawnResult> {
+ const proc = Bun.spawn([...command], {
+ cwd: opts.cwd,
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([
+ Bun.readableStreamToText(proc.stdout),
+ Bun.readableStreamToText(proc.stderr),
+ proc.exited,
+ ]);
+ return { stdout, stderr, exitCode };
+}
+
+function realFs() {
+ return {
+ readText: async (path: string): Promise<string> => Bun.file(path).text(),
+ exists: async (path: string): Promise<boolean> => Bun.file(path).exists(),
+ };
+}
+
+const adapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() };
+
+export function activate(host: HostAPI): void {
+ const storage = host.storage("system-prompt");
+ const service = createSystemPromptService({ storage, adapters });
+ host.provideService(systemPromptHandle, service);
+ host.logger.info("system-prompt: activated");
+}
+
+export const extension: Extension = {
+ manifest,
+ activate,
+};
diff --git a/packages/system-prompt/src/index.ts b/packages/system-prompt/src/index.ts
new file mode 100644
index 0000000..71cc434
--- /dev/null
+++ b/packages/system-prompt/src/index.ts
@@ -0,0 +1,16 @@
+export { getVariableCatalog } from "./catalog.js";
+export { extension, manifest } from "./extension.js";
+export { extractVariables, parseTemplate } from "./parser.js";
+export type {
+ GitSpawn,
+ GitSpawnResult,
+ ResolveOptions,
+ ResolverAdapters,
+ ResolverContext,
+ ResolverFs,
+} from "./resolver.js";
+export { resolveVariables } from "./resolver.js";
+export type { SystemPromptServiceDeps } from "./service.js";
+export { createSystemPromptService, DEFAULT_TEMPLATE } from "./service.js";
+export type { SystemPromptService } from "./types.js";
+export { systemPromptHandle } from "./types.js";
diff --git a/packages/system-prompt/src/parser.test.ts b/packages/system-prompt/src/parser.test.ts
new file mode 100644
index 0000000..636a50d
--- /dev/null
+++ b/packages/system-prompt/src/parser.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, it } from "vitest";
+import { extractVariables, parseTemplate } from "./parser.js";
+
+function vars(entries: ReadonlyArray<[string, string | null]>): Map<string, string | null> {
+ return new Map(entries);
+}
+
+describe("parser", () => {
+ describe("variable insertion", () => {
+ it("simple variable insertion", () => {
+ // 1. [system:time] with value → inserts it
+ expect(parseTemplate("[system:time]", vars([["system:time", "12:00"]]))).toBe("12:00");
+ });
+
+ it("unknown variable → blank", () => {
+ // 2. [unknown:foo] not in map → ""
+ expect(parseTemplate("[unknown:foo]", vars([]))).toBe("");
+ });
+
+ it("null variable → blank", () => {
+ // 3. [file:missing.md] with value null → ""
+ expect(parseTemplate("[file:missing.md]", vars([["file:missing.md", null]]))).toBe("");
+ });
+
+ it("inserts value mid-text", () => {
+ expect(parseTemplate("cwd is [prompt:cwd]!", vars([["prompt:cwd", "/proj"]]))).toBe(
+ "cwd is /proj!",
+ );
+ });
+
+ it("non-tag brackets stay literal", () => {
+ expect(parseTemplate("[not a tag] done", vars([]))).toBe("[not a tag] done");
+ });
+
+ it("unclosed bracket stays literal", () => {
+ expect(parseTemplate("[file:x", vars([]))).toBe("[file:x");
+ });
+ });
+
+ describe("conditionals", () => {
+ it("if block renders when variable exists", () => {
+ // 4. [if file:AGENTS.md]YES[endif] with value → "YES"
+ expect(
+ parseTemplate("[if file:AGENTS.md]YES[endif]", vars([["file:AGENTS.md", "content"]])),
+ ).toBe("YES");
+ });
+
+ it("if block skipped when variable is null", () => {
+ // 5. same with null → ""
+ expect(parseTemplate("[if file:AGENTS.md]YES[endif]", vars([["file:AGENTS.md", null]]))).toBe(
+ "",
+ );
+ });
+
+ it("if block skipped when variable absent", () => {
+ expect(parseTemplate("[if file:AGENTS.md]YES[endif]", vars([]))).toBe("");
+ });
+
+ it("if/else renders fallback when null", () => {
+ // 6. [if file:X]A[else]B[endif] with null → "B"
+ expect(parseTemplate("[if file:X]A[else]B[endif]", vars([["file:X", null]]))).toBe("B");
+ });
+
+ it("if/else renders then-branch when exists", () => {
+ expect(parseTemplate("[if file:X]A[else]B[endif]", vars([["file:X", "v"]]))).toBe("A");
+ });
+
+ it("negated if renders when variable is null", () => {
+ // 7. [if !file:X]A[endif] with null → "A"
+ expect(parseTemplate("[if !file:X]A[endif]", vars([["file:X", null]]))).toBe("A");
+ });
+
+ it("negated if skipped when variable exists", () => {
+ expect(parseTemplate("[if !file:X]A[endif]", vars([["file:X", "v"]]))).toBe("");
+ });
+
+ it("negated if renders when variable absent", () => {
+ expect(parseTemplate("[if !file:X]A[endif]", vars([]))).toBe("A");
+ });
+
+ it("nested if — inner skipped when its var is null", () => {
+ // 8. [if system:os][if file:X]A[endif][endif] with os=set, file=null → ""
+ expect(
+ parseTemplate(
+ "[if system:os][if file:X]A[endif][endif]",
+ vars([
+ ["system:os", "linux"],
+ ["file:X", null],
+ ]),
+ ),
+ ).toBe("");
+ });
+
+ it("nested if — inner renders when both exist", () => {
+ expect(
+ parseTemplate(
+ "[if system:os][if file:X]A[endif][endif]",
+ vars([
+ ["system:os", "linux"],
+ ["file:X", "v"],
+ ]),
+ ),
+ ).toBe("A");
+ });
+
+ it("nested if/else", () => {
+ expect(
+ parseTemplate(
+ "[if system:os]os[if file:X]A[else]B[endif][endif]",
+ vars([
+ ["system:os", "linux"],
+ ["file:X", null],
+ ]),
+ ),
+ ).toBe("osB");
+ });
+
+ it("unmatched if → literal text", () => {
+ // 9. [if file:X]text (no endif) → "[if file:X]text"
+ expect(parseTemplate("[if file:X]text", vars([["file:X", "v"]]))).toBe("[if file:X]text");
+ });
+
+ it("unmatched if with null var still emits literal tag", () => {
+ expect(parseTemplate("[if file:X]text", vars([["file:X", null]]))).toBe("[if file:X]text");
+ });
+
+ it("stray endif → literal text", () => {
+ expect(parseTemplate("a[endif]b", vars([]))).toBe("a[endif]b");
+ });
+
+ it("stray else → literal text", () => {
+ expect(parseTemplate("a[else]b", vars([]))).toBe("a[else]b");
+ });
+
+ it("multi-line content renders correctly", () => {
+ // 10. if block spanning multiple lines
+ const template = "[if file:AGENTS.md]line1\nline2\nline3[endif]";
+ expect(parseTemplate(template, vars([["file:AGENTS.md", "c"]]))).toBe("line1\nline2\nline3");
+ });
+
+ it("multi-line if/else block", () => {
+ const template = "[if file:X]\nA\n[else]\nB\n[endif]";
+ expect(parseTemplate(template, vars([["file:X", null]]))).toBe("\nB\n");
+ });
+
+ it("default-template-like structure renders", () => {
+ const template =
+ "You are a helpful coding assistant.\n\n[if file:AGENTS.md]\n[file:AGENTS.md]\n[endif]\n\nThe current working directory is [prompt:cwd].\n";
+ expect(
+ parseTemplate(
+ template,
+ vars([
+ ["file:AGENTS.md", "RULES"],
+ ["prompt:cwd", "/proj"],
+ ]),
+ ),
+ ).toBe(
+ "You are a helpful coding assistant.\n\n\nRULES\n\n\nThe current working directory is /proj.\n",
+ );
+ });
+
+ it("default-template-like structure without AGENTS.md", () => {
+ const template = "[if file:AGENTS.md]\n[file:AGENTS.md]\n[endif]\nThe cwd is [prompt:cwd].";
+ expect(parseTemplate(template, vars([["prompt:cwd", "/proj"]]))).toBe("\nThe cwd is /proj.");
+ });
+ });
+
+ describe("extractVariables", () => {
+ it("collects insertion + condition keys", () => {
+ const template = "[system:time] [if file:AGENTS.md][file:AGENTS.md][endif] [if !git:branch]";
+ expect(extractVariables(template)).toEqual(["system:time", "file:AGENTS.md", "git:branch"]);
+ });
+
+ it("deduplicates keys", () => {
+ expect(extractVariables("[file:X][if file:X][file:X]")).toEqual(["file:X"]);
+ });
+
+ it("returns empty for plain text", () => {
+ expect(extractVariables("no variables here")).toEqual([]);
+ });
+
+ it("ignores unmatched tags", () => {
+ expect(extractVariables("[if file:X]no endif")).toEqual(["file:X"]);
+ });
+ });
+});
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;
+}
diff --git a/packages/system-prompt/src/resolver.test.ts b/packages/system-prompt/src/resolver.test.ts
new file mode 100644
index 0000000..0585a33
--- /dev/null
+++ b/packages/system-prompt/src/resolver.test.ts
@@ -0,0 +1,210 @@
+import { describe, expect, it } from "vitest";
+import type { GitSpawnResult, ResolverAdapters, ResolverFs } from "./resolver.js";
+import { resolveVariables } from "./resolver.js";
+
+/** A spawn that returns canned output per command (joined argv → result). */
+function fakeSpawn(
+ table: ReadonlyMap<string, GitSpawnResult> | GitSpawnResult,
+): ResolverAdapters["spawn"] {
+ return async (command) => {
+ if (table instanceof Map) {
+ return table.get(command.join(" ")) ?? { stdout: "", stderr: "", exitCode: 128 };
+ }
+ return table;
+ };
+}
+
+function fakeFs(files: ReadonlyMap<string, string>): ResolverFs {
+ return {
+ readText: async (path: string) => files.get(path) ?? "",
+ exists: async (path: string) => files.has(path),
+ };
+}
+
+const failSpawn = (): ResolverAdapters["spawn"] => async () => ({
+ stdout: "",
+ stderr: "not a git repo",
+ exitCode: 128,
+});
+
+const fixedNow = new Date("2024-06-15T12:30:00.000Z");
+
+describe("resolver", () => {
+ describe("system variables", () => {
+ it("resolves system:* to non-null strings", async () => {
+ // 11. system:time, system:date, system:os, system:hostname
+ const map = await resolveVariables("/proj", {
+ spawn: failSpawn(),
+ fs: fakeFs(new Map()),
+ now: () => fixedNow,
+ platform: () => "linux",
+ hostname: () => "myhost",
+ });
+
+ expect(map.get("system:time")).toBe("2024-06-15T12:30:00.000Z");
+ expect(map.get("system:date")).toBe("2024-06-15");
+ expect(map.get("system:os")).toBe("linux");
+ expect(map.get("system:hostname")).toBe("myhost");
+ });
+
+ it("prompt:cwd is the cwd, model/conversation_id follow context", async () => {
+ const map = await resolveVariables(
+ "/proj",
+ {
+ spawn: failSpawn(),
+ fs: fakeFs(new Map()),
+ now: () => fixedNow,
+ },
+ { context: { model: "gpt-4", conversationId: "conv-1" } },
+ );
+
+ expect(map.get("prompt:cwd")).toBe("/proj");
+ expect(map.get("prompt:model")).toBe("gpt-4");
+ expect(map.get("prompt:conversation_id")).toBe("conv-1");
+ });
+
+ it("prompt:model / prompt:conversation_id are null when absent", async () => {
+ const map = await resolveVariables("/proj", {
+ spawn: failSpawn(),
+ fs: fakeFs(new Map()),
+ now: () => fixedNow,
+ });
+
+ expect(map.get("prompt:model")).toBeNull();
+ expect(map.get("prompt:conversation_id")).toBeNull();
+ });
+ });
+
+ describe("file variables", () => {
+ it("reads a file relative to cwd", async () => {
+ // 12. file variable reads relative path; missing → null
+ const files = new Map<string, string>([["/proj/AGENTS.md", "rules"]]);
+ const map = await resolveVariables(
+ "/proj",
+ {
+ spawn: failSpawn(),
+ fs: fakeFs(files),
+ now: () => fixedNow,
+ },
+ { referencedKeys: ["file:AGENTS.md"] },
+ );
+
+ expect(map.get("file:AGENTS.md")).toBe("rules");
+ });
+
+ it("missing file → null", async () => {
+ const map = await resolveVariables(
+ "/proj",
+ {
+ spawn: failSpawn(),
+ fs: fakeFs(new Map()),
+ now: () => fixedNow,
+ },
+ { referencedKeys: ["file:missing.md"] },
+ );
+
+ expect(map.get("file:missing.md")).toBeNull();
+ });
+
+ it("absolute path reads from absolute location", async () => {
+ const files = new Map<string, string>([["/etc/config", "data"]]);
+ const map = await resolveVariables(
+ "/proj",
+ {
+ spawn: failSpawn(),
+ fs: fakeFs(files),
+ now: () => fixedNow,
+ },
+ { referencedKeys: ["file:/etc/config"] },
+ );
+
+ expect(map.get("file:/etc/config")).toBe("data");
+ });
+
+ it("reads nested relative path", async () => {
+ const files = new Map<string, string>([["/proj/src/foo.ts", "export {}"]]);
+ const map = await resolveVariables(
+ "/proj",
+ {
+ spawn: failSpawn(),
+ fs: fakeFs(files),
+ now: () => fixedNow,
+ },
+ { referencedKeys: ["file:src/foo.ts"] },
+ );
+
+ expect(map.get("file:src/foo.ts")).toBe("export {}");
+ });
+
+ it("non-file referenced keys are not added to the map", async () => {
+ const map = await resolveVariables(
+ "/proj",
+ {
+ spawn: failSpawn(),
+ fs: fakeFs(new Map()),
+ now: () => fixedNow,
+ },
+ { referencedKeys: ["unknown:foo"] },
+ );
+
+ expect(map.has("unknown:foo")).toBe(false);
+ });
+ });
+
+ describe("git variables", () => {
+ it("git:branch returns the branch name", async () => {
+ // 13. git:branch via injected spawn
+ const table = new Map<string, GitSpawnResult>([
+ ["git rev-parse --abbrev-ref HEAD", { stdout: "feature/x\n", stderr: "", exitCode: 0 }],
+ ["git status --short", { stdout: " M a.ts\n", stderr: "", exitCode: 0 }],
+ ]);
+ const map = await resolveVariables("/proj", {
+ spawn: fakeSpawn(table),
+ fs: fakeFs(new Map()),
+ now: () => fixedNow,
+ });
+
+ expect(map.get("git:branch")).toBe("feature/x");
+ expect(map.get("git:status")).toBe(" M a.ts");
+ });
+
+ it("non-git cwd → null", async () => {
+ const map = await resolveVariables("/proj", {
+ spawn: failSpawn(),
+ fs: fakeFs(new Map()),
+ now: () => fixedNow,
+ });
+
+ expect(map.get("git:branch")).toBeNull();
+ expect(map.get("git:status")).toBeNull();
+ });
+
+ it("throwing spawn → null", async () => {
+ const throwingSpawn = async (): Promise<GitSpawnResult> => {
+ throw new Error("git not installed");
+ };
+ const map = await resolveVariables("/proj", {
+ spawn: throwingSpawn,
+ fs: fakeFs(new Map()),
+ now: () => fixedNow,
+ });
+
+ expect(map.get("git:branch")).toBeNull();
+ expect(map.get("git:status")).toBeNull();
+ });
+
+ it("clean repo → git:status is empty string (existing)", async () => {
+ const table = new Map<string, GitSpawnResult>([
+ ["git rev-parse --abbrev-ref HEAD", { stdout: "main\n", stderr: "", exitCode: 0 }],
+ ["git status --short", { stdout: "", stderr: "", exitCode: 0 }],
+ ]);
+ const map = await resolveVariables("/proj", {
+ spawn: fakeSpawn(table),
+ fs: fakeFs(new Map()),
+ now: () => fixedNow,
+ });
+
+ expect(map.get("git:status")).toBe("");
+ });
+ });
+});
diff --git a/packages/system-prompt/src/resolver.ts b/packages/system-prompt/src/resolver.ts
new file mode 100644
index 0000000..eed7bcb
--- /dev/null
+++ b/packages/system-prompt/src/resolver.ts
@@ -0,0 +1,139 @@
+/**
+ * Variable resolver — resolves the system-prompt template variables against the
+ * current environment (cwd, system state, git, files).
+ *
+ * The decision logic is pure: all effects (spawning git, reading files) are
+ * injected as adapters. The resolver never touches `Bun`/`process` directly
+ * except through injectable defaults, so it is fully testable with fakes.
+ *
+ * Returns a `Map<string, string | null>` keyed by `"type:name"`:
+ * - `string` → the variable exists with this value.
+ * - `null` → the variable is "not existing" (file missing, git unavailable, …).
+ * Keys that are never set (e.g. an unknown type) are simply absent from the map.
+ */
+
+import { hostname as osHostname } from "node:os";
+import { isAbsolute, resolve as resolvePath } from "node:path";
+
+/** Result of a spawned command (used for git). */
+export interface GitSpawnResult {
+ readonly stdout: string;
+ readonly stderr: string;
+ readonly exitCode: number | null;
+}
+
+/**
+ * Spawn a command and capture its output. Throws are surfaced as `null` by the
+ * resolver (e.g. git not installed, bad cwd).
+ */
+export type GitSpawn = (
+ command: readonly string[],
+ opts: { readonly cwd: string },
+) => Promise<GitSpawnResult>;
+
+/** Filesystem adapter — the read effects the resolver needs. */
+export interface ResolverFs {
+ readonly readText: (path: string) => Promise<string>;
+ readonly exists: (path: string) => Promise<boolean>;
+}
+
+/** Injected effects + optional overridable clocks for deterministic tests. */
+export interface ResolverAdapters {
+ /** Run a command (git) and capture stdout. */
+ readonly spawn: GitSpawn;
+ /** File read effects. */
+ readonly fs: ResolverFs;
+ /** Override the current time (defaults to `new Date()`). */
+ readonly now?: () => Date;
+ /** Override `process.platform` (defaults to the real platform). */
+ readonly platform?: () => string;
+ /** Override the hostname (defaults to `os.hostname()`). */
+ readonly hostname?: () => string;
+}
+
+/** Per-construction context forwarded by the session-orchestrator. */
+export interface ResolverContext {
+ readonly model?: string;
+ readonly conversationId?: string;
+}
+
+export interface ResolveOptions {
+ readonly context?: ResolverContext;
+ /** Variable keys referenced by the template (drives dynamic `file:` reads). */
+ readonly referencedKeys?: readonly string[];
+}
+
+/** Run a git subcommand in `cwd`; return raw stdout on success, else null. */
+async function runGit(
+ args: readonly string[],
+ cwd: string,
+ spawn: GitSpawn,
+): Promise<string | null> {
+ try {
+ const res = await spawn(["git", ...args], { cwd });
+ if (res.exitCode !== 0) return null;
+ return res.stdout;
+ } catch {
+ return null;
+ }
+}
+
+/** Read a file (relative to cwd, or absolute). Missing/error → null. */
+async function readFile(filePath: string, cwd: string, fs: ResolverFs): Promise<string | null> {
+ const abs = isAbsolute(filePath) ? filePath : resolvePath(cwd, filePath);
+ try {
+ if (!(await fs.exists(abs))) return null;
+ return await fs.readText(abs);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Resolve all variables for a construction.
+ *
+ * Always resolves the fixed catalog (`system:*`, `prompt:*`, `git:*`), plus any
+ * `file:<path>` keys present in `options.referencedKeys` (the paths referenced by
+ * the template). Unknown types are intentionally left out of the map.
+ */
+export async function resolveVariables(
+ cwd: string,
+ adapters: ResolverAdapters,
+ options?: ResolveOptions,
+): Promise<Map<string, string | null>> {
+ const ctx = options?.context;
+ const referencedKeys = options?.referencedKeys;
+ const now = adapters.now?.() ?? new Date();
+ const vars = new Map<string, string | null>();
+
+ // ── system:* ────────────────────────────────────────────────────────────
+ vars.set("system:time", now.toISOString());
+ vars.set("system:date", now.toISOString().slice(0, 10));
+ vars.set("system:os", adapters.platform?.() ?? process.platform);
+ vars.set("system:hostname", adapters.hostname?.() ?? osHostname());
+
+ // ── prompt:* ────────────────────────────────────────────────────────────
+ vars.set("prompt:cwd", cwd);
+ vars.set("prompt:model", ctx?.model ?? null);
+ vars.set("prompt:conversation_id", ctx?.conversationId ?? null);
+
+ // ── git:* ────────────────────────────────────────────────────────────────
+ // branch is a single value — trim fully; status keeps its leading status
+ // indicators, dropping only the trailing newline (trimEnd).
+ const branch = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd, adapters.spawn);
+ vars.set("git:branch", branch === null ? null : branch.trim());
+ const status = await runGit(["status", "--short"], cwd, adapters.spawn);
+ vars.set("git:status", status === null ? null : status.trimEnd());
+
+ // ── file:<path> (dynamic — only those referenced by the template) ────────
+ if (referencedKeys !== undefined) {
+ for (const key of referencedKeys) {
+ if (key.startsWith("file:")) {
+ const filePath = key.slice("file:".length);
+ vars.set(key, await readFile(filePath, cwd, adapters.fs));
+ }
+ }
+ }
+
+ return vars;
+}
diff --git a/packages/system-prompt/src/service.test.ts b/packages/system-prompt/src/service.test.ts
new file mode 100644
index 0000000..91592f8
--- /dev/null
+++ b/packages/system-prompt/src/service.test.ts
@@ -0,0 +1,151 @@
+import type { StorageNamespace } from "@dispatch/kernel";
+import { describe, expect, it } from "vitest";
+import type { GitSpawnResult, ResolverAdapters, ResolverFs } from "./resolver.js";
+import { createSystemPromptService, DEFAULT_TEMPLATE } from "./service.js";
+
+/** In-memory StorageNamespace for tests. */
+function memoryStorage(): StorageNamespace {
+ const store = new Map<string, string>();
+ return {
+ get: async (key: string) => store.get(key) ?? null,
+ set: async (key: string, value: string) => {
+ store.set(key, value);
+ },
+ delete: async (key: string) => {
+ store.delete(key);
+ },
+ has: async (key: string) => store.has(key),
+ keys: async (prefix?: string) =>
+ [...store.keys()].filter((k) => (prefix === undefined ? true : k.startsWith(prefix))),
+ };
+}
+
+function fakeFs(files: ReadonlyMap<string, string>): ResolverFs {
+ return {
+ readText: async (path: string) => files.get(path) ?? "",
+ exists: async (path: string) => files.has(path),
+ };
+}
+
+const failSpawn = async (): Promise<GitSpawnResult> => ({
+ stdout: "",
+ stderr: "",
+ exitCode: 128,
+});
+
+function adapters(files: ReadonlyMap<string, string>): ResolverAdapters {
+ return {
+ spawn: failSpawn,
+ fs: fakeFs(files),
+ now: () => new Date("2024-06-15T12:30:00.000Z"),
+ platform: () => "linux",
+ hostname: () => "myhost",
+ };
+}
+
+describe("system-prompt service", () => {
+ it("construct persists and returns the resolved string", async () => {
+ // 14. construct writes to storage and returns the resolved string.
+ const storage = memoryStorage();
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])),
+ });
+
+ const result = await service.construct("conv-1", "/proj", { model: "gpt-4" });
+
+ expect(result).toContain("You are a helpful coding assistant.");
+ expect(result).toContain("RULES");
+ expect(result).toContain("/proj");
+ // persisted under resolved:<conversationId>
+ expect(await storage.get("resolved:conv-1")).toBe(result);
+ });
+
+ it("get returns persisted value after construct", async () => {
+ // 15. after construct, get returns the same string.
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ // before construct → null
+ expect(await service.get("conv-2")).toBeNull();
+
+ const result = await service.construct("conv-2", "/proj");
+ expect(await service.get("conv-2")).toBe(result);
+ });
+
+ it("get returns null before construct", async () => {
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ expect(await service.get("never-constructed")).toBeNull();
+ });
+
+ it("empty/no template stored → default template → non-empty", async () => {
+ // 16. no template stored → default template used → resolves to non-empty.
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()), // no AGENTS.md
+ });
+
+ const result = await service.construct("conv-3", "/proj");
+
+ expect(result.length).toBeGreaterThan(0);
+ expect(result).toContain("You are a helpful coding assistant.");
+ expect(result).toContain("/proj");
+ // no AGENTS.md file → the [if file:AGENTS.md] block is omitted
+ expect(result).not.toContain("AGENTS.md");
+ });
+
+ it("stored template is used instead of default", async () => {
+ const storage = memoryStorage();
+ await storage.set("template", "cwd=[prompt:cwd] os=[system:os]");
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ const result = await service.construct("conv-4", "/work");
+ expect(result).toBe("cwd=/work os=linux");
+ });
+
+ it("empty stored template → empty string", async () => {
+ const storage = memoryStorage();
+ await storage.set("template", "");
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ const result = await service.construct("conv-5", "/proj");
+ expect(result).toBe("");
+ expect(await service.get("conv-5")).toBe("");
+ });
+
+ it("construct is independent per conversation", async () => {
+ const storage = memoryStorage();
+ await storage.set("template", "[prompt:cwd]");
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ const a = await service.construct("conv-a", "/dir-a");
+ const b = await service.construct("conv-b", "/dir-b");
+
+ expect(a).toBe("/dir-a");
+ expect(b).toBe("/dir-b");
+ expect(await service.get("conv-a")).toBe("/dir-a");
+ expect(await service.get("conv-b")).toBe("/dir-b");
+ });
+
+ it("DEFAULT_TEMPLATE contains the expected structure", () => {
+ expect(DEFAULT_TEMPLATE).toContain("You are a helpful coding assistant.");
+ expect(DEFAULT_TEMPLATE).toContain("[if file:AGENTS.md]");
+ expect(DEFAULT_TEMPLATE).toContain("[file:AGENTS.md]");
+ expect(DEFAULT_TEMPLATE).toContain("[prompt:cwd]");
+ });
+});
diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts
new file mode 100644
index 0000000..45645b9
--- /dev/null
+++ b/packages/system-prompt/src/service.ts
@@ -0,0 +1,67 @@
+/**
+ * System-prompt service factory — owns the construct/get decision logic.
+ *
+ * Pure-ish: takes a storage namespace + resolver adapters as deps (both
+ * injectable), so the service is testable with an in-memory storage and fake
+ * adapters. The real Bun-backed adapters are wired in `extension.ts`.
+ */
+import type { StorageNamespace } from "@dispatch/kernel";
+import { extractVariables, parseTemplate } from "./parser.js";
+import type { ResolverAdapters, ResolverContext } from "./resolver.js";
+import { resolveVariables } from "./resolver.js";
+import type { SystemPromptService } from "./types.js";
+
+/**
+ * The default template used when no template has been stored. Embeds the
+ * working directory and an optional `AGENTS.md` (only when the file exists).
+ */
+export const DEFAULT_TEMPLATE = `You are a helpful coding assistant.
+
+[if file:AGENTS.md]
+[file:AGENTS.md]
+[endif]
+
+The current working directory is [prompt:cwd].
+`;
+
+/** Storage keys. */
+const TEMPLATE_KEY = "template";
+const resolvedKey = (conversationId: string): string => `resolved:${conversationId}`;
+
+export interface SystemPromptServiceDeps {
+ /** Namespaced KV (`host.storage("system-prompt")`). */
+ readonly storage: StorageNamespace;
+ /** Injected effects for variable resolution. */
+ readonly adapters: ResolverAdapters;
+}
+
+/**
+ * Create a `SystemPromptService` backed by a storage namespace + adapters.
+ * State is owned (not ambient): the storage reference lives in this closure.
+ */
+export function createSystemPromptService(deps: SystemPromptServiceDeps): SystemPromptService {
+ return {
+ async construct(conversationId, cwd, context) {
+ let template = await deps.storage.get(TEMPLATE_KEY);
+ if (template === null) template = DEFAULT_TEMPLATE;
+
+ const referencedKeys = extractVariables(template);
+ const resolverContext: ResolverContext =
+ context?.model !== undefined
+ ? { model: context.model, conversationId }
+ : { conversationId };
+ const vars = await resolveVariables(cwd, deps.adapters, {
+ context: resolverContext,
+ referencedKeys,
+ });
+ const result = parseTemplate(template, vars);
+
+ await deps.storage.set(resolvedKey(conversationId), result);
+ return result;
+ },
+
+ async get(conversationId) {
+ return deps.storage.get(resolvedKey(conversationId));
+ },
+ };
+}
diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts
new file mode 100644
index 0000000..9e87512
--- /dev/null
+++ b/packages/system-prompt/src/types.ts
@@ -0,0 +1,39 @@
+/**
+ * System-prompt service handle + interface.
+ *
+ * The service is the single-responder anchor the session-orchestrator obtains
+ * (lazily, via `host.getService(systemPromptHandle)`) to construct and read the
+ * per-conversation resolved system prompt.
+ */
+import { defineService, type ServiceHandle } from "@dispatch/kernel";
+
+/**
+ * The system-prompt service.
+ *
+ * `construct` resolves the template once (first turn / compaction) and persists
+ * the result; `get` reads the persisted result on subsequent turns (cache-safe —
+ * no per-turn reconstruction).
+ */
+export interface SystemPromptService {
+ /**
+ * Resolve the template against the current environment and persist the
+ * result under `resolved:<conversationId>`. Returns the resolved string.
+ * When no template is stored, the built-in default template is used. An
+ * empty template yields an empty string.
+ */
+ construct(
+ conversationId: string,
+ cwd: string,
+ context?: { readonly model?: string },
+ ): Promise<string>;
+
+ /** Read the persisted resolved system prompt, or `null` if never constructed. */
+ get(conversationId: string): Promise<string | null>;
+}
+
+/**
+ * Typed handle anchoring the system-prompt service. The single symbol the
+ * session-orchestrator imports to reach the builder — no string-keyed lookup.
+ */
+export const systemPromptHandle: ServiceHandle<SystemPromptService> =
+ defineService<SystemPromptService>("system-prompt");
diff --git a/packages/system-prompt/tsconfig.json b/packages/system-prompt/tsconfig.json
new file mode 100644
index 0000000..883420c
--- /dev/null
+++ b/packages/system-prompt/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }, { "path": "../transport-contract" }]
+}
diff --git a/packages/transport-contract/package.json b/packages/transport-contract/package.json
index 95e1aef..542ed90 100644
--- a/packages/transport-contract/package.json
+++ b/packages/transport-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@dispatch/transport-contract",
- "version": "0.17.0",
+ "version": "0.18.0",
"type": "module",
"private": true,
"main": "dist/index.js",
diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts
index b195171..2a3bb9f 100644
--- a/packages/transport-contract/src/index.ts
+++ b/packages/transport-contract/src/index.ts
@@ -299,6 +299,57 @@ export interface CloseConversationResponse {
readonly abortedTurn: boolean;
}
+// ─── System prompt template ───────────────────────────────────────────────────
+
+/**
+ * Response of `GET /system-prompt` — the current global system prompt template.
+ *
+ * The template is a text string with variable placeholders (`[type:name]`) and
+ * conditional blocks (`[if]`/`[else]`/`[endif]`). At construction time (first
+ * turn or compaction), variables are resolved against the conversation's cwd
+ * and system state. The resolved system prompt is persisted per conversation
+ * and reused on all subsequent turns (cache-safe — no per-turn reconstruction).
+ */
+export interface SystemPromptTemplateResponse {
+ /** The template text (may be empty — then no system prompt is sent). */
+ readonly template: string;
+}
+
+/**
+ * Body of `PUT /system-prompt` — set the global system prompt template.
+ *
+ * Changing the template does NOT affect existing conversations until they are
+ * compacted (the persisted resolved system prompt is stable). New
+ * conversations use the new template on their first turn.
+ */
+export interface SetSystemPromptTemplateRequest {
+ readonly template: string;
+}
+
+/**
+ * One available variable for the system prompt template, as reported by
+ * `GET /system-prompt/variables` so the frontend can render the variable
+ * selector buttons.
+ */
+export interface SystemPromptVariable {
+ /** The variable type/source: `"system"`, `"file"`, `"prompt"`, `"git"`. */
+ readonly type: string;
+ /** The variable name (e.g. `"time"`, `"date"`, `"os"`). For dynamic types, a description. */
+ readonly name: string;
+ /** Human-readable description of what the variable resolves to. */
+ readonly description: string;
+ /**
+ * When `true`, any name is valid for this type (e.g. `file:<path>` accepts
+ * any file path). The frontend should allow free-text input for the name.
+ */
+ readonly dynamic?: boolean;
+}
+
+/** Response of `GET /system-prompt/variables`. */
+export interface SystemPromptVariablesResponse {
+ readonly variables: readonly SystemPromptVariable[];
+}
+
// ─── Message queue (steering) ─────────────────────────────────────────────────
/**
diff --git a/tsconfig.json b/tsconfig.json
index 39f3887..7babef3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -29,6 +29,7 @@
{ "path": "./packages/cache-warming" },
{ "path": "./packages/message-queue" },
{ "path": "./packages/lsp" },
+ { "path": "./packages/system-prompt" },
{ "path": "./packages/cli" },
{ "path": "./packages/journal-sink" },
{ "path": "./packages/trace-store" },