Repository navigation
Expand file tree
/
Copy pathpreferencesStore.ts
More file actions
235 lines (223 loc) · 9.36 KB
/
Copy pathpreferencesStore.ts
File metadata and controls
235 lines (223 loc) · 9.36 KB
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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
import { getCliStateFile, readJsonFile, writeJsonFile } from './cliState.js';
import { getRawCliKnobs } from '../config/config.js';
/**
* Per-workspace runtime preferences that don't justify their own file.
*
* Persisted at `<workspace>/.brainrouter/cli/preferences.json` so they survive
* CLI restarts but stay scoped to the project (different repos can have
* different settings).
*/
export type ExecutionMode = 'planning' | 'fast';
export type ReviewPolicy = 'request' | 'proceed';
export type EffortLevel = 'low' | 'medium' | 'high' | 'xhigh';
export interface Preferences {
/** When true, every worker spawn is auto-followed by a reviewer pass on the diff. */
autoReview: boolean;
/**
* MAS-P4-T4 auto-chain mode. Canonical successor to `autoReview`:
* after a worker finishes, chain `review` / `verify` / `both` follow-ups
* (or `off`). Omitted = derive from `autoReview` for back-compat.
*/
autoChain?: 'off' | 'review' | 'verify' | 'both';
/**
* MAS-P4-T2 supervisor gate. Controls whether/when the agent may spawn
* child agents: auto | ask-before-spawn | ask-before-write-child |
* no-children. Omitted = auto.
*/
delegationPolicy?: 'auto' | 'ask-before-spawn' | 'ask-before-write-child' | 'no-children';
/** Editor mode for the readline composer. */
editorMode: 'emacs' | 'vi';
/** Status-line layout: comma-separated segments from {mode,branch,dirty,model,tokens,session}. */
statusline: string;
/**
* Session execution stance. `planning` (default) routes `run_command`
* through the per-call `askYesNo` confirmation and keeps the system prompt
* leaning toward clarify-before-act. `fast` skips the confirmation for
* non-dangerous commands (see `isDangerousCommand`) and tells the model
* to jump to implementation. Toggle with `/mode`.
*/
executionMode: ExecutionMode;
/**
* Behaviour at workflow / multi-file approval gates. `request` (default)
* keeps today's prose-based "ready for your approval?" gesture in front
* of `/approve`. `proceed` tells the model to apply the plan and report
* after, without the explicit ask. Toggle with `/review-policy`.
*/
reviewPolicy: ReviewPolicy;
/**
* DEPRECATED — superseded by `executionMode` + `reviewPolicy` in 0.3.6.
* Kept on disk so older callers keep functioning during the alias
* transition. New code MUST read `executionMode === 'fast'` instead;
* `readPreferences` back-fills the new fields from this on first read.
*/
autoApproveShell: boolean;
/** Syntax highlighting theme for markdown output. Tied to/theme. */
theme: 'auto' | 'light' | 'dark' | 'mono';
/** Terminal title format segments (model, branch, session, mode) or 'off'. Tied to/title. */
terminalTitle: string;
/** Communication style for the agent. Tied to/personality. */
personality: 'concise' | 'standard' | 'detailed' | 'pair-programmer';
/** When true, REPL output skips markdown rendering for copy-friendly raw text. Tied to/raw. */
rawScrollback: boolean;
/** When true, gated experimental features are unlocked. Tied to/experimental. */
experimental: boolean;
/** When true, the memory pipeline runs phase1/phase2 consolidation on session start. Tied to/memories. */
memoriesEnabled: boolean;
/** Custom keybindings as JSON-stringified map for /keymap. Empty means defaults. */
keymap: string;
/** Extra read-only paths granted to sandboxed run_command. Workspace is always readable+writable. */
sandboxReadPaths: string[];
/** Extra write-allowed paths granted to sandboxed run_command. */
sandboxWritePaths: string[];
/**
* When true, hide non-essential chrome from the REPL: briefing/recall
* tables, tool-completion previews, spawn dumps. Leaves spinner + model
* prose. Tied to /quiet and the --quiet startup flag. Off by default.
*/
quiet: boolean;
/**
* Reasoning depth preference. `medium` (default) is today's behaviour and
* emits no system-prompt overlay and no provider-side reasoning slot.
* `low` adds a "be terse, skip ceremony" overlay; `high` adds a
* step-by-step audit overlay. When the LLM endpoint exposes a reasoning
* slot (gpt-5 / o-series via Chat Completions accept `reasoning_effort`),
* the level is also forwarded as the provider-native enum. Tied to
* `/effort` and the `BRAINROUTER_EFFORT` env override.
*/
effort: EffortLevel;
/**
* 0.3.9 item 13 — model-tier pin. `flash | standard | pro` pin the
* model for the rest of the session through `/tier`. `null` (the
* default) means "follow the model field as-is; allow self-escalation
* via the <<<NEEDS_HIGH>>> marker".
*/
tier?: 'flash' | 'standard' | 'pro' | null;
/**
* When true (default), the brain's distilled Core Identity is pinned
* into the cache-stable briefing prefix on every turn. `false`
* suppresses persona injection without deleting the underlying
* `core_identity` row. Tied to `/persona on|off`. Layered with the
* system-wide `cli.personaAnchor` knob in `config.json`; both must
* be on for the anchor to fire. Per the 0.3.9 env→config migration,
* no `BRAINROUTER_*` env var is consulted.
*/
personaAnchorEnabled: boolean;
}
const DEFAULT: Preferences = {
autoReview: false,
editorMode: 'emacs',
statusline: 'mode',
executionMode: 'planning',
reviewPolicy: 'request',
autoApproveShell: false,
theme: 'auto',
terminalTitle: 'model,session',
personality: 'standard',
rawScrollback: false,
experimental: false,
memoriesEnabled: true,
keymap: '',
sandboxReadPaths: [],
sandboxWritePaths: [],
quiet: false,
effort: 'medium',
personaAnchorEnabled: true,
};
/**
* Back-fill `executionMode` / `reviewPolicy` from the legacy
* `autoApproveShell` flag when an older prefs file is read for the first
* time. Migration is read-only and idempotent: if either new field is
* already present we leave both alone (the user has already opted into the
* new model and any drift between the two is intentional). The legacy field
* stays on disk so other readers don't break during the alias period.
*/
function migrateLegacyShell(stored: Partial<Preferences>): Partial<Preferences> {
const hasNewFields = stored.executionMode !== undefined || stored.reviewPolicy !== undefined;
if (hasNewFields) return stored;
if (stored.autoApproveShell !== true) return stored;
return {
...stored,
executionMode: 'fast',
reviewPolicy: 'proceed',
};
}
export function readPreferences(workspaceRoot: string): Preferences {
const stored = readJsonFile<Partial<Preferences>>(
getCliStateFile(workspaceRoot, 'preferences.json'),
{},
);
return { ...DEFAULT, ...migrateLegacyShell(stored) };
}
export function writePreferences(workspaceRoot: string, prefs: Partial<Preferences>): Preferences {
const merged = { ...readPreferences(workspaceRoot), ...prefs };
writeJsonFile(getCliStateFile(workspaceRoot, 'preferences.json'), merged);
return merged;
}
/**
* `/yolo on` shorthand: flip both new fields to their "do not interrupt me"
* setting. We also keep the legacy `autoApproveShell` mirror in sync so any
* external tooling that still inspects it sees a consistent state during
* the alias period.
*/
export function applyYoloOn(workspaceRoot: string): Preferences {
return writePreferences(workspaceRoot, {
executionMode: 'fast',
reviewPolicy: 'proceed',
autoApproveShell: true,
});
}
/**
* `/yolo off` shorthand: restore the conservative defaults on both axes.
*/
export function applyYoloOff(workspaceRoot: string): Preferences {
return writePreferences(workspaceRoot, {
executionMode: 'planning',
reviewPolicy: 'request',
autoApproveShell: false,
});
}
export interface ResolvedEffort {
effort: EffortLevel;
source: 'config' | 'preference' | 'default';
}
/**
* Normalize a raw effort string to a canonical `EffortLevel`, or undefined if
* unrecognized. `max` is an accepted alias for `xhigh` — they mean the same
* thing (maximum reasoning depth); we canonicalize to `xhigh` so only one
* value is ever stored/forwarded. Case-insensitive. Exported for tests.
*/
export function normalizeEffort(raw: unknown): EffortLevel | undefined {
if (typeof raw !== 'string') return undefined;
const v = raw.trim().toLowerCase();
if (v === 'max' || v === 'xhigh') return 'xhigh';
return v === 'low' || v === 'medium' || v === 'high' ? v : undefined;
}
/**
* Resolve the active reasoning-depth level using config > preference > default.
* Matches the precedence pattern set by `resolveTheme` and the `cli.quiet`
* override.
*
* A garbled `cli.effort` value is treated as unset so users don't get
* cryptic crashes — the per-workspace preference takes over.
*
* We read the raw file (not `readPreferences`) so we can distinguish a
* preference that was explicitly written from the default that
* `readPreferences` injects via its spread.
*/
export function resolveEffort(workspaceRoot?: string): ResolvedEffort {
const cfgEffort = normalizeEffort(getRawCliKnobs().effort);
if (cfgEffort) return { effort: cfgEffort, source: 'config' };
if (workspaceRoot) {
try {
const stored = readJsonFile<Partial<Preferences>>(
getCliStateFile(workspaceRoot, 'preferences.json'),
{},
);
const prefEffort = normalizeEffort(stored.effort);
if (prefEffort) return { effort: prefEffort, source: 'preference' };
} catch {
// Preferences file unreadable — fall through to default.
}
}
return { effort: 'medium', source: 'default' };
}