summaryrefslogtreecommitdiffhomepage
path: root/packages/system-prompt/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
commit61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch)
tree2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/system-prompt/src
parent63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff)
parent727c98c9dae516a2070eb950410314380a20c974 (diff)
downloaddispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz
dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/system-prompt/src')
-rw-r--r--packages/system-prompt/src/catalog.test.ts46
-rw-r--r--packages/system-prompt/src/catalog.ts36
-rw-r--r--packages/system-prompt/src/extension.ts206
-rw-r--r--packages/system-prompt/src/index.ts12
-rw-r--r--packages/system-prompt/src/parser.test.ts356
-rw-r--r--packages/system-prompt/src/parser.ts346
-rw-r--r--packages/system-prompt/src/resolver.test.ts542
-rw-r--r--packages/system-prompt/src/resolver.ts236
-rw-r--r--packages/system-prompt/src/service.test.ts414
-rw-r--r--packages/system-prompt/src/service.ts126
-rw-r--r--packages/system-prompt/src/types.ts74
11 files changed, 1197 insertions, 1197 deletions
diff --git a/packages/system-prompt/src/catalog.test.ts b/packages/system-prompt/src/catalog.test.ts
index 406455b..12999b6 100644
--- a/packages/system-prompt/src/catalog.test.ts
+++ b/packages/system-prompt/src/catalog.test.ts
@@ -2,30 +2,30 @@ 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}`);
+ 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");
- });
+ 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("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);
- }
- });
+ 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
index 1c825ab..f4df390 100644
--- a/packages/system-prompt/src/catalog.ts
+++ b/packages/system-prompt/src/catalog.ts
@@ -8,22 +8,22 @@
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: "prompt", name: "workspace_id", description: "Workspace 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,
- },
- ];
+ 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: "prompt", name: "workspace_id", description: "Workspace 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
index 5cb9125..d281bf8 100644
--- a/packages/system-prompt/src/extension.ts
+++ b/packages/system-prompt/src/extension.ts
@@ -17,43 +17,43 @@ 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",
- // exec-backend provides the resolver used to obtain a remote ExecBackend
- // when computerId is set. The lookup is lazy (at construct time, not
- // activation), but declaring the dep keeps the DAG honest.
- dependsOn: ["exec-backend"],
- capabilities: { fs: true, spawn: true },
- contributes: { services: ["system-prompt"] },
+ id: "system-prompt",
+ name: "System Prompt",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ // exec-backend provides the resolver used to obtain a remote ExecBackend
+ // when computerId is set. The lookup is lazy (at construct time, not
+ // activation), but declaring the dep keeps the DAG honest.
+ dependsOn: ["exec-backend"],
+ 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 },
+ 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 };
+ 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(),
- };
+ return {
+ readText: async (path: string): Promise<string> => Bun.file(path).text(),
+ exists: async (path: string): Promise<boolean> => Bun.file(path).exists(),
+ };
}
const localAdapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() };
@@ -63,25 +63,25 @@ const localAdapters: ResolverAdapters = { spawn: realSpawn, fs: realFs() };
* `null` on any error (the resolver treats null as "unavailable").
*/
async function remoteCommand(
- backend: ExecBackend,
- command: string,
- cwd: string,
+ backend: ExecBackend,
+ command: string,
+ cwd: string,
): Promise<string | null> {
- let stdout = "";
- try {
- const result = await backend.spawn({
- command,
- cwd,
- signal: new AbortController().signal,
- timeout: 10_000,
- onOutput: (data: string, stream: "stdout" | "stderr") => {
- if (stream === "stdout") stdout += data;
- },
- });
- return result.exitCode === 0 ? stdout.trim() : null;
- } catch {
- return null;
- }
+ let stdout = "";
+ try {
+ const result = await backend.spawn({
+ command,
+ cwd,
+ signal: new AbortController().signal,
+ timeout: 10_000,
+ onOutput: (data: string, stream: "stdout" | "stderr") => {
+ if (stream === "stdout") stdout += data;
+ },
+ });
+ return result.exitCode === 0 ? stdout.trim() : null;
+ } catch {
+ return null;
+ }
}
/**
@@ -90,68 +90,68 @@ async function remoteCommand(
* prompt reflect the REMOTE machine's OS, hostname, and git state.
*/
function buildRemoteAdapters(backend: ExecBackend, cwd: string): ResolverAdapters {
- return {
- spawn: async (command, opts) => {
- let stdout = "";
- let stderr = "";
- const result = await backend.spawn({
- command: command.join(" "),
- cwd: opts.cwd,
- signal: new AbortController().signal,
- timeout: 10_000,
- onOutput: (data: string, stream: "stdout" | "stderr") => {
- if (stream === "stdout") stdout += data;
- else stderr += data;
- },
- });
- return { stdout, stderr, exitCode: result.exitCode };
- },
- fs: {
- readText: async (path: string): Promise<string> => backend.readFile(path),
- exists: async (path: string): Promise<boolean> => backend.exists(path),
- },
- // Run hostname/uname on the REMOTE machine. These are resolved once per
- // construct call (cached by the service's cwd+computerId cache). If the
- // remote command fails, fall back to a generic value (the resolver will
- // still read /etc/os-release via SFTP for the distro name).
- hostname: async () => (await remoteCommand(backend, "hostname", cwd)) ?? "remote",
- platform: async () => (await remoteCommand(backend, "uname -s", cwd)) ?? "linux",
- };
+ return {
+ spawn: async (command, opts) => {
+ let stdout = "";
+ let stderr = "";
+ const result = await backend.spawn({
+ command: command.join(" "),
+ cwd: opts.cwd,
+ signal: new AbortController().signal,
+ timeout: 10_000,
+ onOutput: (data: string, stream: "stdout" | "stderr") => {
+ if (stream === "stdout") stdout += data;
+ else stderr += data;
+ },
+ });
+ return { stdout, stderr, exitCode: result.exitCode };
+ },
+ fs: {
+ readText: async (path: string): Promise<string> => backend.readFile(path),
+ exists: async (path: string): Promise<boolean> => backend.exists(path),
+ },
+ // Run hostname/uname on the REMOTE machine. These are resolved once per
+ // construct call (cached by the service's cwd+computerId cache). If the
+ // remote command fails, fall back to a generic value (the resolver will
+ // still read /etc/os-release via SFTP for the distro name).
+ hostname: async () => (await remoteCommand(backend, "hostname", cwd)) ?? "remote",
+ platform: async () => (await remoteCommand(backend, "uname -s", cwd)) ?? "linux",
+ };
}
export function activate(host: HostAPI): void {
- const storage = host.storage("system-prompt");
+ const storage = host.storage("system-prompt");
- /**
- * Resolve remote-backed adapters for a given computerId. Looks up the
- * ExecBackendResolver (provided by exec-backend, which delegates to ssh's
- * remote factory when computerId is set) and wraps it in ResolverAdapters.
- * Falls back to local adapters if the resolver or backend is unavailable.
- */
- const resolveRemoteAdapters = async (
- computerId: string,
- cwd: string,
- ): Promise<ResolverAdapters> => {
- try {
- const resolver = host.getService(execBackendHandle);
- const backend = resolver(computerId);
- return buildRemoteAdapters(backend, cwd);
- } catch {
- // exec-backend not loaded or resolver unavailable → local.
- return localAdapters;
- }
- };
+ /**
+ * Resolve remote-backed adapters for a given computerId. Looks up the
+ * ExecBackendResolver (provided by exec-backend, which delegates to ssh's
+ * remote factory when computerId is set) and wraps it in ResolverAdapters.
+ * Falls back to local adapters if the resolver or backend is unavailable.
+ */
+ const resolveRemoteAdapters = async (
+ computerId: string,
+ cwd: string,
+ ): Promise<ResolverAdapters> => {
+ try {
+ const resolver = host.getService(execBackendHandle);
+ const backend = resolver(computerId);
+ return buildRemoteAdapters(backend, cwd);
+ } catch {
+ // exec-backend not loaded or resolver unavailable → local.
+ return localAdapters;
+ }
+ };
- const service = createSystemPromptService({
- storage,
- adapters: localAdapters,
- resolveRemoteAdapters,
- });
- host.provideService(systemPromptHandle, service);
- host.logger.info("system-prompt: activated");
+ const service = createSystemPromptService({
+ storage,
+ adapters: localAdapters,
+ resolveRemoteAdapters,
+ });
+ host.provideService(systemPromptHandle, service);
+ host.logger.info("system-prompt: activated");
}
export const extension: Extension = {
- manifest,
- activate,
+ manifest,
+ activate,
};
diff --git a/packages/system-prompt/src/index.ts b/packages/system-prompt/src/index.ts
index 71cc434..35fd091 100644
--- a/packages/system-prompt/src/index.ts
+++ b/packages/system-prompt/src/index.ts
@@ -2,12 +2,12 @@ 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,
+ GitSpawn,
+ GitSpawnResult,
+ ResolveOptions,
+ ResolverAdapters,
+ ResolverContext,
+ ResolverFs,
} from "./resolver.js";
export { resolveVariables } from "./resolver.js";
export type { SystemPromptServiceDeps } from "./service.js";
diff --git a/packages/system-prompt/src/parser.test.ts b/packages/system-prompt/src/parser.test.ts
index 636a50d..3e3cdb6 100644
--- a/packages/system-prompt/src/parser.test.ts
+++ b/packages/system-prompt/src/parser.test.ts
@@ -2,185 +2,185 @@ 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);
+ 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"]);
- });
- });
+ 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
index 5b39b7f..d01e6a8 100644
--- a/packages/system-prompt/src/parser.ts
+++ b/packages/system-prompt/src/parser.ts
@@ -19,48 +19,48 @@
// ─── Token model ─────────────────────────────────────────────────────────────
interface TextToken {
- readonly kind: "text";
- readonly value: string;
+ readonly kind: "text";
+ readonly value: string;
}
interface VarToken {
- readonly kind: "var";
- readonly key: string;
- readonly raw: string;
+ readonly kind: "var";
+ readonly key: string;
+ readonly raw: string;
}
interface IfToken {
- readonly kind: "if";
- readonly key: string;
- readonly negated: boolean;
- readonly raw: string;
+ readonly kind: "if";
+ readonly key: string;
+ readonly negated: boolean;
+ readonly raw: string;
}
interface ElseToken {
- readonly kind: "else";
- readonly raw: string;
+ readonly kind: "else";
+ readonly raw: string;
}
interface EndifToken {
- readonly kind: "endif";
- readonly raw: string;
+ readonly kind: "endif";
+ readonly raw: string;
}
type Token = TextToken | VarToken | IfToken | ElseToken | EndifToken;
// ─── Node model (AST) ────────────────────────────────────────────────────────
interface TextNode {
- readonly kind: "text";
- readonly value: string;
+ readonly kind: "text";
+ readonly value: string;
}
interface VarNode {
- readonly kind: "var";
- readonly key: string;
+ readonly kind: "var";
+ readonly key: string;
}
interface IfNode {
- kind: "if";
- key: string;
- negated: boolean;
- thenBranch: Node[];
- else: Node[] | null;
- matched: boolean;
- readonly raw: string;
+ kind: "if";
+ key: string;
+ negated: boolean;
+ thenBranch: Node[];
+ else: Node[] | null;
+ matched: boolean;
+ readonly raw: string;
}
type Node = TextNode | VarNode | IfNode;
@@ -71,23 +71,23 @@ type Node = TextNode | VarNode | IfNode;
* `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}]` };
+ 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}]` };
- }
+ // `[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}]` };
+ // `[type:name]` — variable insertion (any `word:rest`)
+ const varMatch = /^(\w+:.*)$/.exec(trimmed);
+ if (varMatch) return { kind: "var", key: trimmed, raw: `[${content}]` };
- return null;
+ return null;
}
/**
@@ -96,45 +96,45 @@ function classifyTag(content: string): Token | null {
* as literal text.
*/
function tokenize(template: string): Token[] {
- const tokens: Token[] = [];
- let buf = "";
- let i = 0;
- const n = template.length;
+ 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 = "";
- }
- };
+ 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;
+ 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) ─────────────────────────────────────────────
@@ -147,99 +147,99 @@ const EMPTY: readonly Node[] = Object.freeze([]) as readonly Node[];
* 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;
+ 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;
- }
- }
- }
+ 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;
+ // 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;
+ 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;
+ 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 ──────────────────────────────────────────────────────────────
@@ -254,9 +254,9 @@ function render(nodes: readonly Node[], vars: ReadonlyMap<string, string | null>
* - 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);
+ const tokens = tokenize(template);
+ const ast = parse(tokens);
+ return render(ast, vars);
}
/**
@@ -266,16 +266,16 @@ export function parseTemplate(template: string, vars: ReadonlyMap<string, string
* 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;
+ 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
index d55af07..a92e43f 100644
--- a/packages/system-prompt/src/resolver.test.ts
+++ b/packages/system-prompt/src/resolver.test.ts
@@ -4,287 +4,287 @@ import { resolveVariables } from "./resolver.js";
/** A spawn that returns canned output per command (joined argv → result). */
function fakeSpawn(
- table: ReadonlyMap<string, GitSpawnResult> | GitSpawnResult,
+ 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;
- };
+ 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),
- };
+ 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,
+ 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: async () => "linux",
- hostname: async () => "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("system:os rich resolution", () => {
- it("returns distro from /etc/os-release PRETTY_NAME on Linux", async () => {
- const files = new Map<string, string>([
- ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\nNAME="Ubuntu"\n'],
- ]);
- const map = await resolveVariables("/proj", {
- spawn: failSpawn(),
- fs: fakeFs(files),
- platform: async () => "linux",
- });
- expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS");
- });
-
- it("falls back to NAME + VERSION_ID when no PRETTY_NAME", async () => {
- const files = new Map<string, string>([
- ["/etc/os-release", 'NAME="Debian"\nVERSION_ID="12"\n'],
- ]);
- const map = await resolveVariables("/proj", {
- spawn: failSpawn(),
- fs: fakeFs(files),
- platform: async () => "linux",
- });
- expect(map.get("system:os")).toBe("Debian 12");
- });
-
- it("appends (WSL) when WSLInterop exists", async () => {
- const files = new Map<string, string>([
- ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'],
- ["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"],
- ]);
- const map = await resolveVariables("/proj", {
- spawn: failSpawn(),
- fs: fakeFs(files),
- platform: async () => "linux",
- });
- expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)");
- });
-
- it("detects WSL via 'microsoft' in /proc/version", async () => {
- const files = new Map<string, string>([
- ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'],
- ["/proc/version", "Linux version 5.15.153.1-microsoft-standard-WSL2\n"],
- ]);
- const map = await resolveVariables("/proj", {
- spawn: failSpawn(),
- fs: fakeFs(files),
- platform: async () => "linux",
- });
- expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)");
- });
-
- it("returns 'Linux (WSL)' when WSL detected but no distro info", async () => {
- const files = new Map<string, string>([["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"]]);
- const map = await resolveVariables("/proj", {
- spawn: failSpawn(),
- fs: fakeFs(files),
- platform: async () => "linux",
- });
- expect(map.get("system:os")).toBe("Linux (WSL)");
- });
-
- it("returns plain 'linux' when no os-release and no WSL", async () => {
- const map = await resolveVariables("/proj", {
- spawn: failSpawn(),
- fs: fakeFs(new Map()),
- platform: async () => "linux",
- });
- expect(map.get("system:os")).toBe("linux");
- });
-
- it("returns platform as-is for non-Linux (darwin)", async () => {
- const map = await resolveVariables("/proj", {
- spawn: failSpawn(),
- fs: fakeFs(new Map()),
- platform: async () => "darwin",
- });
- expect(map.get("system:os")).toBe("darwin");
- });
- });
-
- 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("");
- });
- });
+ 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: async () => "linux",
+ hostname: async () => "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("system:os rich resolution", () => {
+ it("returns distro from /etc/os-release PRETTY_NAME on Linux", async () => {
+ const files = new Map<string, string>([
+ ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\nNAME="Ubuntu"\n'],
+ ]);
+ const map = await resolveVariables("/proj", {
+ spawn: failSpawn(),
+ fs: fakeFs(files),
+ platform: async () => "linux",
+ });
+ expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS");
+ });
+
+ it("falls back to NAME + VERSION_ID when no PRETTY_NAME", async () => {
+ const files = new Map<string, string>([
+ ["/etc/os-release", 'NAME="Debian"\nVERSION_ID="12"\n'],
+ ]);
+ const map = await resolveVariables("/proj", {
+ spawn: failSpawn(),
+ fs: fakeFs(files),
+ platform: async () => "linux",
+ });
+ expect(map.get("system:os")).toBe("Debian 12");
+ });
+
+ it("appends (WSL) when WSLInterop exists", async () => {
+ const files = new Map<string, string>([
+ ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'],
+ ["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"],
+ ]);
+ const map = await resolveVariables("/proj", {
+ spawn: failSpawn(),
+ fs: fakeFs(files),
+ platform: async () => "linux",
+ });
+ expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)");
+ });
+
+ it("detects WSL via 'microsoft' in /proc/version", async () => {
+ const files = new Map<string, string>([
+ ["/etc/os-release", 'PRETTY_NAME="Ubuntu 22.04 LTS"\n'],
+ ["/proc/version", "Linux version 5.15.153.1-microsoft-standard-WSL2\n"],
+ ]);
+ const map = await resolveVariables("/proj", {
+ spawn: failSpawn(),
+ fs: fakeFs(files),
+ platform: async () => "linux",
+ });
+ expect(map.get("system:os")).toBe("Ubuntu 22.04 LTS (WSL)");
+ });
+
+ it("returns 'Linux (WSL)' when WSL detected but no distro info", async () => {
+ const files = new Map<string, string>([["/proc/sys/fs/binfmt_misc/WSLInterop", "enabled\n"]]);
+ const map = await resolveVariables("/proj", {
+ spawn: failSpawn(),
+ fs: fakeFs(files),
+ platform: async () => "linux",
+ });
+ expect(map.get("system:os")).toBe("Linux (WSL)");
+ });
+
+ it("returns plain 'linux' when no os-release and no WSL", async () => {
+ const map = await resolveVariables("/proj", {
+ spawn: failSpawn(),
+ fs: fakeFs(new Map()),
+ platform: async () => "linux",
+ });
+ expect(map.get("system:os")).toBe("linux");
+ });
+
+ it("returns platform as-is for non-Linux (darwin)", async () => {
+ const map = await resolveVariables("/proj", {
+ spawn: failSpawn(),
+ fs: fakeFs(new Map()),
+ platform: async () => "darwin",
+ });
+ expect(map.get("system:os")).toBe("darwin");
+ });
+ });
+
+ 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
index e864554..8b397d2 100644
--- a/packages/system-prompt/src/resolver.ts
+++ b/packages/system-prompt/src/resolver.ts
@@ -17,9 +17,9 @@ 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;
+ readonly stdout: string;
+ readonly stderr: string;
+ readonly exitCode: number | null;
}
/**
@@ -27,73 +27,73 @@ export interface GitSpawnResult {
* resolver (e.g. git not installed, bad cwd).
*/
export type GitSpawn = (
- command: readonly string[],
- opts: { readonly cwd: string },
+ 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>;
+ 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). Async so a
- * remote adapter can run `uname -s` over SSH.
- */
- readonly platform?: () => Promise<string>;
- /**
- * Override the hostname (defaults to `os.hostname()`). Async so a remote
- * adapter can run `hostname` over SSH.
- */
- readonly hostname?: () => Promise<string>;
+ /** 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). Async so a
+ * remote adapter can run `uname -s` over SSH.
+ */
+ readonly platform?: () => Promise<string>;
+ /**
+ * Override the hostname (defaults to `os.hostname()`). Async so a remote
+ * adapter can run `hostname` over SSH.
+ */
+ readonly hostname?: () => Promise<string>;
}
/** Per-construction context forwarded by the session-orchestrator. */
export interface ResolverContext {
- readonly model?: string;
- readonly conversationId?: string;
- readonly workspaceId?: string;
+ readonly model?: string;
+ readonly conversationId?: string;
+ readonly workspaceId?: string;
}
export interface ResolveOptions {
- readonly context?: ResolverContext;
- /** Variable keys referenced by the template (drives dynamic `file:` reads). */
- readonly referencedKeys?: readonly string[];
+ 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,
+ 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;
- }
+ 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;
- }
+ const abs = isAbsolute(filePath) ? filePath : resolvePath(cwd, filePath);
+ try {
+ if (!(await fs.exists(abs))) return null;
+ return await fs.readText(abs);
+ } catch {
+ return null;
+ }
}
/**
@@ -109,38 +109,38 @@ async function readFile(filePath: string, cwd: string, fs: ResolverFs): Promise<
* to the base platform string). The `platform` override is honored for tests.
*/
async function resolveOs(platform: string, fs: ResolverFs): Promise<string> {
- if (platform !== "linux") return platform;
-
- let distro: string | null = null;
- const osRelease = await readFile("/etc/os-release", "/", fs);
- if (osRelease !== null) {
- const pretty = osRelease.match(/^PRETTY_NAME="(.+)"/m);
- if (pretty?.[1] !== undefined) {
- distro = pretty[1];
- } else {
- const name = osRelease.match(/^NAME="(.+)"/m);
- const version = osRelease.match(/^VERSION_ID="(.+)"/m);
- if (name?.[1] !== undefined) {
- distro = version?.[1] !== undefined ? `${name[1]} ${version[1]}` : name[1];
- }
- }
- }
-
- let isWsl = false;
- const wslInterop = await readFile("/proc/sys/fs/binfmt_misc/WSLInterop", "/", fs);
- if (wslInterop !== null) {
- isWsl = true;
- } else {
- const procVersion = await readFile("/proc/version", "/", fs);
- if (procVersion !== null && /microsoft/i.test(procVersion)) {
- isWsl = true;
- }
- }
-
- if (distro !== null) {
- return isWsl ? `${distro} (WSL)` : distro;
- }
- return isWsl ? "Linux (WSL)" : "linux";
+ if (platform !== "linux") return platform;
+
+ let distro: string | null = null;
+ const osRelease = await readFile("/etc/os-release", "/", fs);
+ if (osRelease !== null) {
+ const pretty = osRelease.match(/^PRETTY_NAME="(.+)"/m);
+ if (pretty?.[1] !== undefined) {
+ distro = pretty[1];
+ } else {
+ const name = osRelease.match(/^NAME="(.+)"/m);
+ const version = osRelease.match(/^VERSION_ID="(.+)"/m);
+ if (name?.[1] !== undefined) {
+ distro = version?.[1] !== undefined ? `${name[1]} ${version[1]}` : name[1];
+ }
+ }
+ }
+
+ let isWsl = false;
+ const wslInterop = await readFile("/proc/sys/fs/binfmt_misc/WSLInterop", "/", fs);
+ if (wslInterop !== null) {
+ isWsl = true;
+ } else {
+ const procVersion = await readFile("/proc/version", "/", fs);
+ if (procVersion !== null && /microsoft/i.test(procVersion)) {
+ isWsl = true;
+ }
+ }
+
+ if (distro !== null) {
+ return isWsl ? `${distro} (WSL)` : distro;
+ }
+ return isWsl ? "Linux (WSL)" : "linux";
}
/**
@@ -151,45 +151,45 @@ async function resolveOs(platform: string, fs: ResolverFs): Promise<string> {
* the template). Unknown types are intentionally left out of the map.
*/
export async function resolveVariables(
- cwd: string,
- adapters: ResolverAdapters,
- options?: ResolveOptions,
+ 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));
- const platform = (await adapters.platform?.()) ?? process.platform;
- vars.set("system:os", await resolveOs(platform, adapters.fs));
- vars.set("system:hostname", (await adapters.hostname?.()) ?? osHostname());
-
- // ── prompt:* ────────────────────────────────────────────────────────────
- vars.set("prompt:cwd", cwd);
- vars.set("prompt:model", ctx?.model ?? null);
- vars.set("prompt:conversation_id", ctx?.conversationId ?? null);
- vars.set("prompt:workspace_id", ctx?.workspaceId ?? 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;
+ 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));
+ const platform = (await adapters.platform?.()) ?? process.platform;
+ vars.set("system:os", await resolveOs(platform, adapters.fs));
+ vars.set("system:hostname", (await adapters.hostname?.()) ?? osHostname());
+
+ // ── prompt:* ────────────────────────────────────────────────────────────
+ vars.set("prompt:cwd", cwd);
+ vars.set("prompt:model", ctx?.model ?? null);
+ vars.set("prompt:conversation_id", ctx?.conversationId ?? null);
+ vars.set("prompt:workspace_id", ctx?.workspaceId ?? 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
index 37c1c0d..08e3d33 100644
--- a/packages/system-prompt/src/service.test.ts
+++ b/packages/system-prompt/src/service.test.ts
@@ -5,223 +5,223 @@ 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))),
- };
+ 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),
- };
+ return {
+ readText: async (path: string) => files.get(path) ?? "",
+ exists: async (path: string) => files.has(path),
+ };
}
const failSpawn = async (): Promise<GitSpawnResult> => ({
- stdout: "",
- stderr: "",
- exitCode: 128,
+ 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",
- };
+ 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]");
- });
-
- it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => {
- // 1. never constructed → both fields null.
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()),
- });
-
- const meta = await service.getWithMeta("never-constructed");
- expect(meta).toEqual({ prompt: null, cwd: null, computerId: null });
- });
-
- it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => {
- // 2. after construct → prompt + exact cwd passed to construct.
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])),
- });
-
- const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" });
- const meta = await service.getWithMeta("conv-meta");
-
- expect(meta.prompt).toBe(result);
- expect(meta.cwd).toBe("/proj");
- });
-
- it("get still returns the same value as before (backward compat)", async () => {
- // 3. get() behavior is unchanged by the additive getWithMeta.
- const service = createSystemPromptService({
- storage: memoryStorage(),
- adapters: adapters(new Map()),
- });
-
- // before construct → null
- expect(await service.get("conv-bc")).toBeNull();
-
- const result = await service.construct("conv-bc", "/proj");
- expect(await service.get("conv-bc")).toBe(result);
- });
-
- it("construct called twice with different cwds stores the latest cwd", async () => {
- // 4. second construct overwrites the cwd (not the first).
- const storage = memoryStorage();
- await storage.set("template", "[prompt:cwd]");
- const service = createSystemPromptService({
- storage,
- adapters: adapters(new Map()),
- });
-
- await service.construct("conv-twice", "/first");
- expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first");
-
- const second = await service.construct("conv-twice", "/second");
- expect(second).toBe("/second");
- expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second");
- expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first");
- });
-
- it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => {
- // 5. getWithMeta reflects the latest construct, not the first.
- const storage = memoryStorage();
- await storage.set("template", "[prompt:cwd]");
- const service = createSystemPromptService({
- storage,
- adapters: adapters(new Map()),
- });
-
- const first = await service.construct("conv-second", "/dir-a");
- const firstMeta = await service.getWithMeta("conv-second");
- expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null });
-
- const second = await service.construct("conv-second", "/dir-b");
- const secondMeta = await service.getWithMeta("conv-second");
- expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null });
- expect(secondMeta.cwd).not.toBe("/dir-a");
- });
+ 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]");
+ });
+
+ it("getWithMeta on a never-constructed conversation returns { prompt: null, cwd: null }", async () => {
+ // 1. never constructed → both fields null.
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ const meta = await service.getWithMeta("never-constructed");
+ expect(meta).toEqual({ prompt: null, cwd: null, computerId: null });
+ });
+
+ it("getWithMeta after construct returns the resolved prompt and the exact cwd", async () => {
+ // 2. after construct → prompt + exact cwd passed to construct.
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map([["/proj/AGENTS.md", "RULES"]])),
+ });
+
+ const result = await service.construct("conv-meta", "/proj", { model: "gpt-4" });
+ const meta = await service.getWithMeta("conv-meta");
+
+ expect(meta.prompt).toBe(result);
+ expect(meta.cwd).toBe("/proj");
+ });
+
+ it("get still returns the same value as before (backward compat)", async () => {
+ // 3. get() behavior is unchanged by the additive getWithMeta.
+ const service = createSystemPromptService({
+ storage: memoryStorage(),
+ adapters: adapters(new Map()),
+ });
+
+ // before construct → null
+ expect(await service.get("conv-bc")).toBeNull();
+
+ const result = await service.construct("conv-bc", "/proj");
+ expect(await service.get("conv-bc")).toBe(result);
+ });
+
+ it("construct called twice with different cwds stores the latest cwd", async () => {
+ // 4. second construct overwrites the cwd (not the first).
+ const storage = memoryStorage();
+ await storage.set("template", "[prompt:cwd]");
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ await service.construct("conv-twice", "/first");
+ expect(await storage.get("resolved-cwd:conv-twice")).toBe("/first");
+
+ const second = await service.construct("conv-twice", "/second");
+ expect(second).toBe("/second");
+ expect(await storage.get("resolved-cwd:conv-twice")).toBe("/second");
+ expect(await storage.get("resolved-cwd:conv-twice")).not.toBe("/first");
+ });
+
+ it("getWithMeta after a second construct with a different cwd returns the new cwd and new prompt", async () => {
+ // 5. getWithMeta reflects the latest construct, not the first.
+ const storage = memoryStorage();
+ await storage.set("template", "[prompt:cwd]");
+ const service = createSystemPromptService({
+ storage,
+ adapters: adapters(new Map()),
+ });
+
+ const first = await service.construct("conv-second", "/dir-a");
+ const firstMeta = await service.getWithMeta("conv-second");
+ expect(firstMeta).toEqual({ prompt: first, cwd: "/dir-a", computerId: null });
+
+ const second = await service.construct("conv-second", "/dir-b");
+ const secondMeta = await service.getWithMeta("conv-second");
+ expect(secondMeta).toEqual({ prompt: second, cwd: "/dir-b", computerId: null });
+ expect(secondMeta.cwd).not.toBe("/dir-a");
+ });
});
diff --git a/packages/system-prompt/src/service.ts b/packages/system-prompt/src/service.ts
index 8d6ede5..34ecbe7 100644
--- a/packages/system-prompt/src/service.ts
+++ b/packages/system-prompt/src/service.ts
@@ -29,20 +29,20 @@ const TEMPLATE_KEY = "template";
const resolvedKey = (conversationId: string): string => `resolved:${conversationId}`;
const resolvedCwdKey = (conversationId: string): string => `resolved-cwd:${conversationId}`;
const resolvedComputerIdKey = (conversationId: string): string =>
- `resolved-computer:${conversationId}`;
+ `resolved-computer:${conversationId}`;
export interface SystemPromptServiceDeps {
- /** Namespaced KV (`host.storage("system-prompt")`). */
- readonly storage: StorageNamespace;
- /** Injected effects for variable resolution (local). */
- readonly adapters: ResolverAdapters;
- /**
- * Optional: build remote-backed adapters for a given computerId. When
- * `construct` is called with a `computerId`, this is invoked to obtain
- * adapters that read/run commands on the REMOTE machine (via the
- * ExecBackend/SSH). Absent → falls back to the local `adapters`.
- */
- readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise<ResolverAdapters>;
+ /** Namespaced KV (`host.storage("system-prompt")`). */
+ readonly storage: StorageNamespace;
+ /** Injected effects for variable resolution (local). */
+ readonly adapters: ResolverAdapters;
+ /**
+ * Optional: build remote-backed adapters for a given computerId. When
+ * `construct` is called with a `computerId`, this is invoked to obtain
+ * adapters that read/run commands on the REMOTE machine (via the
+ * ExecBackend/SSH). Absent → falls back to the local `adapters`.
+ */
+ readonly resolveRemoteAdapters?: (computerId: string, cwd: string) => Promise<ResolverAdapters>;
}
/**
@@ -50,63 +50,63 @@ export interface SystemPromptServiceDeps {
* 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;
+ 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 = {
- conversationId,
- ...(context?.model !== undefined ? { model: context.model } : {}),
- ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}),
- };
+ const referencedKeys = extractVariables(template);
+ const resolverContext: ResolverContext = {
+ conversationId,
+ ...(context?.model !== undefined ? { model: context.model } : {}),
+ ...(context?.workspaceId !== undefined ? { workspaceId: context.workspaceId } : {}),
+ };
- // Select adapters: when computerId is set, use remote-backed adapters
- // (read files / run commands on the REMOTE machine via SSH). Otherwise
- // use the local adapters.
- const computerId = context?.computerId;
- const adapters =
- computerId !== undefined && deps.resolveRemoteAdapters !== undefined
- ? await deps.resolveRemoteAdapters(computerId, cwd)
- : deps.adapters;
+ // Select adapters: when computerId is set, use remote-backed adapters
+ // (read files / run commands on the REMOTE machine via SSH). Otherwise
+ // use the local adapters.
+ const computerId = context?.computerId;
+ const adapters =
+ computerId !== undefined && deps.resolveRemoteAdapters !== undefined
+ ? await deps.resolveRemoteAdapters(computerId, cwd)
+ : deps.adapters;
- const vars = await resolveVariables(cwd, adapters, {
- context: resolverContext,
- referencedKeys,
- });
- const result = parseTemplate(template, vars);
+ const vars = await resolveVariables(cwd, adapters, {
+ context: resolverContext,
+ referencedKeys,
+ });
+ const result = parseTemplate(template, vars);
- await deps.storage.set(resolvedKey(conversationId), result);
- await deps.storage.set(resolvedCwdKey(conversationId), cwd);
- // Store the computerId (or empty string for local) so the cache can be
- // invalidated when the computer changes.
- await deps.storage.set(resolvedComputerIdKey(conversationId), computerId ?? "");
- return result;
- },
+ await deps.storage.set(resolvedKey(conversationId), result);
+ await deps.storage.set(resolvedCwdKey(conversationId), cwd);
+ // Store the computerId (or empty string for local) so the cache can be
+ // invalidated when the computer changes.
+ await deps.storage.set(resolvedComputerIdKey(conversationId), computerId ?? "");
+ return result;
+ },
- async get(conversationId) {
- return deps.storage.get(resolvedKey(conversationId));
- },
+ async get(conversationId) {
+ return deps.storage.get(resolvedKey(conversationId));
+ },
- async getWithMeta(conversationId) {
- const [prompt, cwd, computerIdStored] = await Promise.all([
- deps.storage.get(resolvedKey(conversationId)),
- deps.storage.get(resolvedCwdKey(conversationId)),
- deps.storage.get(resolvedComputerIdKey(conversationId)),
- ]);
- // Empty string → null (local, no computerId). Non-empty → the alias.
- const computerId = computerIdStored === null ? null : computerIdStored || null;
- return { prompt, cwd, computerId };
- },
+ async getWithMeta(conversationId) {
+ const [prompt, cwd, computerIdStored] = await Promise.all([
+ deps.storage.get(resolvedKey(conversationId)),
+ deps.storage.get(resolvedCwdKey(conversationId)),
+ deps.storage.get(resolvedComputerIdKey(conversationId)),
+ ]);
+ // Empty string → null (local, no computerId). Non-empty → the alias.
+ const computerId = computerIdStored === null ? null : computerIdStored || null;
+ return { prompt, cwd, computerId };
+ },
- async getTemplate() {
- const stored = await deps.storage.get(TEMPLATE_KEY);
- return stored ?? DEFAULT_TEMPLATE;
- },
+ async getTemplate() {
+ const stored = await deps.storage.get(TEMPLATE_KEY);
+ return stored ?? DEFAULT_TEMPLATE;
+ },
- async setTemplate(template) {
- await deps.storage.set(TEMPLATE_KEY, template);
- },
- };
+ async setTemplate(template) {
+ await deps.storage.set(TEMPLATE_KEY, template);
+ },
+ };
}
diff --git a/packages/system-prompt/src/types.ts b/packages/system-prompt/src/types.ts
index a9fe3ca..534814e 100644
--- a/packages/system-prompt/src/types.ts
+++ b/packages/system-prompt/src/types.ts
@@ -15,46 +15,46 @@ import { defineService, type ServiceHandle } from "@dispatch/kernel";
* 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.
- *
- * When `context.computerId` is set, the resolver uses remote-backed adapters
- * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via
- * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine.
- */
- construct(
- conversationId: string,
- cwd: string,
- context?: {
- readonly model?: string;
- readonly workspaceId?: string;
- readonly computerId?: string;
- },
- ): Promise<string>;
+ /**
+ * 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.
+ *
+ * When `context.computerId` is set, the resolver uses remote-backed adapters
+ * (reading the remote's `/etc/os-release`, `hostname`, `uname`, `git` via
+ * the ExecBackend/SSH) so the system prompt reflects the REMOTE machine.
+ */
+ construct(
+ conversationId: string,
+ cwd: string,
+ context?: {
+ readonly model?: string;
+ readonly workspaceId?: string;
+ readonly computerId?: string;
+ },
+ ): Promise<string>;
- /** Read the persisted resolved system prompt, or `null` if never constructed. */
- get(conversationId: string): Promise<string | null>;
+ /** Read the persisted resolved system prompt, or `null` if never constructed. */
+ get(conversationId: string): Promise<string | null>;
- /**
- * Read the persisted resolved system prompt AND the cwd + computerId it was
- * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if
- * never constructed. Consumers use this to detect whether the cached prompt
- * is stale relative to the current effective cwd or computerId.
- */
- getWithMeta(conversationId: string): Promise<{
- readonly prompt: string | null;
- readonly cwd: string | null;
- readonly computerId: string | null;
- }>;
+ /**
+ * Read the persisted resolved system prompt AND the cwd + computerId it was
+ * built against. Returns `{ prompt: null, cwd: null, computerId: null }` if
+ * never constructed. Consumers use this to detect whether the cached prompt
+ * is stale relative to the current effective cwd or computerId.
+ */
+ getWithMeta(conversationId: string): Promise<{
+ readonly prompt: string | null;
+ readonly cwd: string | null;
+ readonly computerId: string | null;
+ }>;
- /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */
- getTemplate(): Promise<string>;
+ /** Read the global template (or `DEFAULT_TEMPLATE` when none is stored). */
+ getTemplate(): Promise<string>;
- /** Set (upsert) the global template. An empty string means "no system prompt". */
- setTemplate(template: string): Promise<void>;
+ /** Set (upsert) the global template. An empty string means "no system prompt". */
+ setTemplate(template: string): Promise<void>;
}
/**
@@ -62,4 +62,4 @@ export interface SystemPromptService {
* session-orchestrator imports to reach the builder — no string-keyed lookup.
*/
export const systemPromptHandle: ServiceHandle<SystemPromptService> =
- defineService<SystemPromptService>("system-prompt");
+ defineService<SystemPromptService>("system-prompt");