summaryrefslogtreecommitdiffhomepage
path: root/packages/system-prompt/src/service.ts
blob: 8d6ede550b163e1a085583b9d42d09751651f1b4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
/**
 * System-prompt service factory — owns the construct/get decision logic.
 *
 * Pure-ish: takes a storage namespace + resolver adapters as deps (both
 * injectable), so the service is testable with an in-memory storage and fake
 * adapters. The real Bun-backed adapters are wired in `extension.ts`.
 */
import type { StorageNamespace } from "@dispatch/kernel";
import { extractVariables, parseTemplate } from "./parser.js";
import type { ResolverAdapters, ResolverContext } from "./resolver.js";
import { resolveVariables } from "./resolver.js";
import type { SystemPromptService } from "./types.js";

/**
 * The default template used when no template has been stored. Embeds the
 * working directory and an optional `AGENTS.md` (only when the file exists).
 */
export const DEFAULT_TEMPLATE = `You are a helpful coding assistant.

[if file:AGENTS.md]
[file:AGENTS.md]
[endif]

The current working directory is [prompt:cwd].
`;

/** Storage keys. */
const TEMPLATE_KEY = "template";
const resolvedKey = (conversationId: string): string => `resolved:${conversationId}`;
const resolvedCwdKey = (conversationId: string): string => `resolved-cwd:${conversationId}`;
const resolvedComputerIdKey = (conversationId: string): string =>
	`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>;
}

/**
 * Create a `SystemPromptService` backed by a storage namespace + adapters.
 * State is owned (not ambient): the storage reference lives in this closure.
 */
export function createSystemPromptService(deps: SystemPromptServiceDeps): SystemPromptService {
	return {
		async construct(conversationId, cwd, context) {
			let template = await deps.storage.get(TEMPLATE_KEY);
			if (template === null) template = DEFAULT_TEMPLATE;

			const referencedKeys = extractVariables(template);
			const resolverContext: ResolverContext = {
				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;

			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;
		},

		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 getTemplate() {
			const stored = await deps.storage.get(TEMPLATE_KEY);
			return stored ?? DEFAULT_TEMPLATE;
		},

		async setTemplate(template) {
			await deps.storage.set(TEMPLATE_KEY, template);
		},
	};
}