diff options
| author | Adam Malczewski <[email protected]> | 2026-06-23 23:04:30 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-23 23:04:30 +0900 |
| commit | 674853d87d54dba1cd83c4e51fce5411602f4d5d (patch) | |
| tree | 07455f9753a09a5ca66f8cb885a37ba3c0cb7787 | |
| parent | 4158e699e3c8ff556684fe2fc7a39ffab040623e (diff) | |
| download | dispatch-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.lock | 12 | ||||
| -rw-r--r-- | notes/system-prompt-design.md | 213 | ||||
| -rw-r--r-- | packages/system-prompt/package.json | 12 | ||||
| -rw-r--r-- | packages/system-prompt/src/catalog.test.ts | 31 | ||||
| -rw-r--r-- | packages/system-prompt/src/catalog.ts | 28 | ||||
| -rw-r--r-- | packages/system-prompt/src/extension.ts | 62 | ||||
| -rw-r--r-- | packages/system-prompt/src/index.ts | 16 | ||||
| -rw-r--r-- | packages/system-prompt/src/parser.test.ts | 186 | ||||
| -rw-r--r-- | packages/system-prompt/src/parser.ts | 281 | ||||
| -rw-r--r-- | packages/system-prompt/src/resolver.test.ts | 210 | ||||
| -rw-r--r-- | packages/system-prompt/src/resolver.ts | 139 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.test.ts | 151 | ||||
| -rw-r--r-- | packages/system-prompt/src/service.ts | 67 | ||||
| -rw-r--r-- | packages/system-prompt/src/types.ts | 39 | ||||
| -rw-r--r-- | packages/system-prompt/tsconfig.json | 6 | ||||
| -rw-r--r-- | packages/transport-contract/package.json | 2 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 51 | ||||
| -rw-r--r-- | tsconfig.json | 1 |
18 files changed, 1505 insertions, 2 deletions
@@ -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" }, |
