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 /packages | |
| 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.
Diffstat (limited to 'packages')
| -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 |
15 files changed, 1280 insertions, 1 deletions
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) ───────────────────────────────────────────────── /** |
