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/system-prompt/src/resolver.ts | |
| 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/system-prompt/src/resolver.ts')
| -rw-r--r-- | packages/system-prompt/src/resolver.ts | 139 |
1 files changed, 139 insertions, 0 deletions
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; +} |
