summaryrefslogtreecommitdiffhomepage
path: root/packages/system-prompt/src/resolver.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-23 23:04:30 +0900
committerAdam Malczewski <[email protected]>2026-06-23 23:04:30 +0900
commit674853d87d54dba1cd83c4e51fce5411602f4d5d (patch)
tree07455f9753a09a5ca66f8cb885a37ba3c0cb7787 /packages/system-prompt/src/resolver.ts
parent4158e699e3c8ff556684fe2fc7a39ffab040623e (diff)
downloaddispatch-674853d87d54dba1cd83c4e51fce5411602f4d5d.tar.gz
dispatch-674853d87d54dba1cd83c4e51fce5411602f4d5d.zip
feat(system-prompt): template-based system prompt builder extension
New @dispatch/system-prompt extension (standard tier): - Pure parser: [type:name] variables, [if]/[else]/[endif] conditionals, negated [if !...], nested blocks, unmatched-tag pass-through. - Variable resolver (injected adapters): system:time/date/os/hostname, prompt:cwd/model/conversation_id, git:branch/status, file:<path> (dynamic). - Service handle: construct (resolve+persist) + get (cached, cache-safe). - Default template: persona + AGENTS.md if exists + cwd. - 52 tests (parser 29, resolver 12, catalog 3, service 8). transport-contract 0.17.0→0.18.0: SystemPromptTemplateResponse, SetSystemPromptTemplateRequest, SystemPromptVariable, SystemPromptVariablesResponse. Design: notes/system-prompt-design.md (caching constraint, compaction integration, wave plan). 1384 vitest pass.
Diffstat (limited to 'packages/system-prompt/src/resolver.ts')
-rw-r--r--packages/system-prompt/src/resolver.ts139
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;
+}