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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
|
# System Prompt Builder — design + plan
> Orchestrator-authored plan. Drives the `prompts/<unit>.md` TASK blocks.
> User-approved: all variable types, `[else]`, negated `[if !...]`, caching, compaction append.
## 1. Goal
A **template-based system prompt builder** that lets the user define a system prompt
with variable placeholders (`[type:name]`) and conditionals (`[if]`/`[else]`/`[endif]`).
Variables are resolved at **construction time** (not every turn) to preserve the prompt
cache.
Hard requirements:
- Template persisted globally; resolved system prompt persisted **per conversation**.
- **Cache-safe:** constructed once (first turn), reused on all subsequent turns.
Reconstructed only on **compaction** (fresh variable resolution).
- Compaction appends its instructions to the constructed system prompt.
- API: GET/PUT template, GET available variables.
- Frontend: full-page modal (text editor + variable selector buttons).
## 2. Template format
### Variable insertion
```
[type:name]
```
Resolves the variable at construction time. Unknown type → blank string.
Non-existent variable (e.g. file not found) → blank string.
### Conditional blocks
```
[if type:name]
...content if variable exists...
[else]
...content if variable does NOT exist...
[endif]
```
Negated condition:
```
[if !type:name]
...content if variable does NOT exist...
[endif]
```
- Nested `[if]` blocks: supported.
- Multi-line content between tags: supported.
- Unmatched `[if]`/`[endif]`: treated as literal text (not parsed).
### Edge cases
- Unknown variable type (e.g. `[unknown:foo]`) → blank string.
- File read failure → variable is "not existing" → blank string; `[if]` → skipped.
- Empty template → no system prompt sent (current behavior preserved).
## 3. Variable types
| Type:Name | Description | Source |
|---|---|---|
| `system:time` | Current time (ISO 8601) | `new Date().toISOString()` |
| `system:date` | Current date (YYYY-MM-DD) | derived from `new Date()` |
| `system:os` | Operating system | `process.platform` |
| `system:hostname` | Machine hostname | `require("node:os").hostname()` |
| `prompt:cwd` | Conversation's working directory | passed from session-orchestrator |
| `prompt:model` | Current model name | passed from session-orchestrator |
| `prompt:conversation_id` | Conversation ID | passed from session-orchestrator |
| `git:branch` | Current git branch | `git rev-parse --abbrev-ref HEAD` in cwd |
| `git:status` | Short git status | `git status --short` in cwd |
| `file:<path>` | File contents (relative to cwd, or absolute if starts `/`) | `fs.readText` |
The `file:` type is **dynamic** — any path is a valid variable name. All others have
fixed names. The `git:` variables require `spawn` capability; failures (not a git repo,
git not installed) → variable is "not existing" → blank string.
## 4. Caching constraint (critical)
**The system prompt is constructed ONCE (on the first turn of a conversation) and
persisted.** Subsequent turns reuse the persisted system prompt — no reconstruction.
This preserves the prompt cache (the system prompt is part of the cacheable prefix).
**Reconstruction happens only on compaction** (the conversation is being summarized,
so fresh variable resolution makes sense — files may have changed, cwd may have changed,
the time is stale).
Flow:
1. **First turn** (new conversation): session-orchestrator detects `getConversationMeta
=== null` → calls `systemPromptService.construct(conversationId, cwd, {model})` →
resolves all variables → persists the result → returns the string → sets on
`providerOpts.systemPrompt`.
2. **Subsequent turns:** session-orchestrator calls
`systemPromptService.get(conversationId)` → returns the persisted string (or null if
never constructed) → sets on `providerOpts.systemPrompt` (or omits if null).
3. **Compaction turn:** session-orchestrator calls
`systemPromptService.construct(conversationId, cwd, {model})` → gets fresh resolved
system prompt → appends `COMPACTION_SYSTEM_PROMPT` → uses the combined string as the
compaction turn's system prompt. The `construct` call also persists the reconstructed
system prompt (without compaction instructions) for future turns.
4. **Post-compaction turns:** session-orchestrator calls
`systemPromptService.get(conversationId)` → returns the reconstructed system prompt.
Changing the template (via `PUT /system-prompt`) does NOT affect existing conversations
until they are compacted. New conversations use the new template on their first turn.
## 5. Default template
When no template has been set, a built-in default is used:
```
You are a helpful coding assistant.
[if file:AGENTS.md]
[file:AGENTS.md]
[endif]
The current working directory is [prompt:cwd].
```
## 6. Architecture
### `system-prompt` extension (new, standard tier)
**Manifest:** `id: "system-prompt"`, `capabilities: { fs: true, spawn: true }`,
`dependsOn: []` (depends only on kernel contracts).
**Pure parser** (`parser.ts`):
- `parseTemplate(template: string, resolvedVars: Map<string, string | null>): string`
- Handles `[type:name]` insertion, `[if]`/`[else]`/`[endif]` conditionals, nesting.
- Pure: input → output, no I/O. Fully testable with zero mocks.
**Variable resolver** (`resolver.ts`):
- `resolveVariables(cwd: string, context?: { model?: string; conversationId?: string }):
Promise<Map<string, string | null>>`
- Reads files (`fs` capability), runs git commands (`spawn` capability), gets system
info. Injected adapters (same pattern as LSP extension).
- Returns `Map<string, string | null>` — `null` means "not existing" (file not found,
git not available, etc.).
**Variable catalog** (`catalog.ts`):
- `getVariableCatalog(): SystemPromptVariable[]` — the static list of available variables
(for `GET /system-prompt/variables`). The `file:` type is marked `dynamic: true`.
**Service handle** (`types.ts`):
```ts
export interface SystemPromptService {
construct(conversationId: string, cwd: string, context?: {
readonly model?: string;
}): Promise<string>;
get(conversationId: string): Promise<string | null>;
}
```
- `construct`: reads the template from storage, resolves all variables, persists the
result under `system-prompt:resolved:<conversationId>`, returns the string.
- `get`: reads the persisted result (or null).
**Storage:**
- Template: `host.storage("system-prompt")` key `"template"` (global).
- Resolved system prompt: `host.storage("system-prompt")` key
`"resolved:<conversationId>"` (per conversation).
### Session-orchestrator integration
The session-orchestrator wires `systemPromptService` as an **optional dep** (same pattern
as `message-queue` / `toolsFilter` — lazily via `host.getService(systemPromptHandle)` in
`activate`). If the system-prompt extension isn't loaded, no system prompt is sent
(current behavior preserved).
Changes in `orchestrator.ts`:
1. **Turn flow (~line 461):** after resolving `effectiveCwd`, determine if this is the
first turn (new conversation) or a subsequent turn:
- First turn (new conversation, detected via `getConversationMeta === null` already
checked earlier for workspace assignment): call
`deps.resolveSystemPrompt?.construct(conversationId, effectiveCwd, { model: modelName })`.
- Subsequent turns: call `deps.resolveSystemPrompt?.get(conversationId)`.
- If the result is non-null, set it on `providerOpts.systemPrompt`.
2. **Compaction flow (~line 911):** currently sets `systemPrompt: COMPACTION_SYSTEM_PROMPT`.
Change to: call `deps.resolveSystemPrompt?.construct(conversationId, cwd, { model })` →
get the constructed system prompt → append `"\n\n" + COMPACTION_SYSTEM_PROMPT` → set on
`providerOpts.systemPrompt`. If the service is unavailable, fall back to just
`COMPACTION_SYSTEM_PROMPT` (current behavior).
### API endpoints (transport-http)
| Method | Path | Description |
|---|---|---|
| GET | `/system-prompt` | Returns `{ template: string }` |
| PUT | `/system-prompt` | Body `{ template: string }` → persists, returns `{ template: string }` |
| GET | `/system-prompt/variables` | Returns `{ variables: SystemPromptVariable[] }` |
The transport-http extension calls the `systemPromptHandle` service for these (same
pattern as `lspServiceHandle` for the LSP status endpoint). If the service is unavailable,
`GET` returns the default template, `PUT` returns 503, `GET /variables` returns the
static catalog.
## 7. Units & waves
### Wave 0 (orchestrator, contracts)
- `transport-contract`: add `SystemPromptTemplateResponse`,
`SetSystemPromptTemplateRequest`, `SystemPromptVariable`,
`SystemPromptVariablesResponse`. Version bump.
### Wave 1
- `system-prompt` (NEW extension): pure parser + variable resolver + catalog + storage
+ service handle. Depends only on kernel contracts. Tests: parser (pure, zero mocks),
resolver (injected adapters), catalog.
### Wave 2 (parallel — disjoint packages)
- `session-orchestrator`: wire `systemPromptService` as optional dep; construct on first
turn, get on subsequent, construct+append on compaction. + tests.
- `transport-http`: add `GET/PUT /system-prompt` + `GET /system-prompt/variables` routes.
+ tests.
### Wave 3
- `host-bin`: register `system-prompt` in `CORE_EXTENSIONS`.
- Orchestrator: root tsconfig ref + `bun install`.
### Wave 4
- Full-graph verify + FE courier handoff.
|