@brainjar/cli 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +11 -10
  2. package/package.json +2 -2
  3. package/src/api-types.ts +155 -0
  4. package/src/cli.ts +5 -3
  5. package/src/client.ts +157 -0
  6. package/src/commands/brain.ts +99 -113
  7. package/src/commands/compose.ts +17 -116
  8. package/src/commands/init.ts +66 -42
  9. package/src/commands/migrate.ts +61 -0
  10. package/src/commands/pack.ts +1 -5
  11. package/src/commands/persona.ts +97 -145
  12. package/src/commands/rules.ts +71 -174
  13. package/src/commands/server.ts +212 -0
  14. package/src/commands/shell.ts +55 -51
  15. package/src/commands/soul.ts +75 -110
  16. package/src/commands/status.ts +37 -78
  17. package/src/commands/sync.ts +0 -2
  18. package/src/config.ts +125 -0
  19. package/src/daemon.ts +404 -0
  20. package/src/errors.ts +172 -0
  21. package/src/migrate.ts +247 -0
  22. package/src/pack.ts +149 -428
  23. package/src/paths.ts +1 -8
  24. package/src/seeds.ts +62 -105
  25. package/src/state.ts +12 -397
  26. package/src/sync.ts +61 -102
  27. package/src/version-check.ts +137 -0
  28. package/src/brain.ts +0 -69
  29. package/src/commands/identity.ts +0 -276
  30. package/src/engines/bitwarden.ts +0 -105
  31. package/src/engines/index.ts +0 -12
  32. package/src/engines/types.ts +0 -12
  33. package/src/hooks.test.ts +0 -132
  34. package/src/pack.test.ts +0 -472
  35. package/src/seeds/templates/persona.md +0 -19
  36. package/src/seeds/templates/rule.md +0 -11
  37. package/src/seeds/templates/soul.md +0 -20
  38. /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
  39. /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
  40. /package/src/seeds/rules/{default/task-completion.md → task-completion.md} +0 -0
package/src/paths.ts CHANGED
@@ -37,12 +37,5 @@ export function getLocalDir() {
37
37
 
38
38
  export const paths = {
39
39
  get root() { return getBrainjarDir() },
40
- get souls() { return join(getBrainjarDir(), 'souls') },
41
- get personas() { return join(getBrainjarDir(), 'personas') },
42
- get rules() { return join(getBrainjarDir(), 'rules') },
43
- get brains() { return join(getBrainjarDir(), 'brains') },
44
- get identities() { return join(getBrainjarDir(), 'identities') },
45
- get session() { return join(getBrainjarDir(), '.session') },
46
- get state() { return join(getBrainjarDir(), 'state.yaml') },
47
- get localState() { return join(getLocalDir(), 'state.yaml') },
40
+ get config() { return join(getBrainjarDir(), 'config.yaml') },
48
41
  }
package/src/seeds.ts CHANGED
@@ -1,116 +1,73 @@
1
- import { mkdir, readdir, writeFile, copyFile } from 'node:fs/promises'
1
+ import { readFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import { paths } from './paths.js'
3
+ import type { ContentBundle } from './api-types.js'
4
+ import { parseFrontmatter } from './migrate.js'
4
5
 
5
6
  const SEEDS_DIR = join(import.meta.dir, 'seeds')
6
7
 
7
- // ---------------------------------------------------------------------------
8
- // Obsidian vault configuration
9
- // ---------------------------------------------------------------------------
10
-
11
- function obsidianAppearanceConfig() {
12
- return JSON.stringify({
13
- accentColor: '',
14
- baseFontSize: 16,
15
- }, null, 2)
8
+ async function readSeed(relPath: string): Promise<string> {
9
+ return readFile(join(SEEDS_DIR, relPath), 'utf-8')
16
10
  }
17
11
 
18
12
  /**
19
- * Obsidian file-explorer exclusion via userIgnoreFilters.
20
- * Excludes private/state files from the vault file explorer.
13
+ * Build a ContentBundle from the embedded seed files.
14
+ * This bundle can be POSTed to /api/v1/import.
21
15
  */
22
- function obsidianAppConfigWithExclusions() {
23
- return JSON.stringify({
24
- showLineNumber: true,
25
- strictLineBreaks: true,
26
- useMarkdownLinks: false,
27
- alwaysUpdateLinks: true,
28
- userIgnoreFilters: [
29
- 'identities/',
30
- 'state.yaml',
31
- '.session',
32
- '.gitignore',
33
- ],
34
- }, null, 2)
35
- }
36
-
37
- function obsidianCorePlugins() {
38
- return JSON.stringify({
39
- 'file-explorer': true,
40
- 'global-search': true,
41
- 'graph': true,
42
- 'tag-pane': true,
43
- 'templates': true,
44
- 'outline': true,
45
- 'editor-status': true,
46
- 'starred': true,
47
- 'command-palette': true,
48
- 'markdown-importer': false,
49
- 'word-count': true,
50
- }, null, 2)
51
- }
52
-
53
- function obsidianTemplatesConfig() {
54
- return JSON.stringify({
55
- folder: 'templates',
56
- }, null, 2)
57
- }
58
-
59
- // ---------------------------------------------------------------------------
60
- // Seed functions
61
- // ---------------------------------------------------------------------------
62
-
63
- /** Copy a seed .md file from src/seeds/ to a target path. */
64
- async function copySeed(seedRelPath: string, destPath: string) {
65
- await copyFile(join(SEEDS_DIR, seedRelPath), destPath)
66
- }
67
-
68
- /** Seed the default rule pack — the baseline every persona references */
69
- export async function seedDefaultRule(rulesDir: string) {
70
- const defaultDir = join(rulesDir, 'default')
71
- await mkdir(defaultDir, { recursive: true })
72
-
73
- const seedDir = join(SEEDS_DIR, 'rules', 'default')
74
- const files = await readdir(seedDir)
75
- await Promise.all(
76
- files.filter(f => f.endsWith('.md')).map(f =>
77
- copySeed(join('rules', 'default', f), join(defaultDir, f))
78
- )
79
- )
80
- }
81
-
82
- /** Seed starter content: soul, personas, and rules */
83
- export async function seedDefaults() {
84
- await Promise.all([
85
- // Soul
86
- copySeed('souls/craftsman.md', join(paths.souls, 'craftsman.md')),
87
-
88
- // Personas
89
- copySeed('personas/engineer.md', join(paths.personas, 'engineer.md')),
90
- copySeed('personas/planner.md', join(paths.personas, 'planner.md')),
91
- copySeed('personas/reviewer.md', join(paths.personas, 'reviewer.md')),
92
-
93
- // Rules
94
- copySeed('rules/git-discipline.md', join(paths.rules, 'git-discipline.md')),
95
- copySeed('rules/security.md', join(paths.rules, 'security.md')),
16
+ export async function buildSeedBundle(): Promise<ContentBundle> {
17
+ const [
18
+ craftsmanContent,
19
+ engineerContent,
20
+ plannerContent,
21
+ reviewerContent,
22
+ boundariesContent,
23
+ contextRecoveryContent,
24
+ taskCompletionContent,
25
+ gitDisciplineContent,
26
+ securityContent,
27
+ ] = await Promise.all([
28
+ readSeed('souls/craftsman.md'),
29
+ readSeed('personas/engineer.md'),
30
+ readSeed('personas/planner.md'),
31
+ readSeed('personas/reviewer.md'),
32
+ readSeed('rules/boundaries.md'),
33
+ readSeed('rules/context-recovery.md'),
34
+ readSeed('rules/task-completion.md'),
35
+ readSeed('rules/git-discipline.md'),
36
+ readSeed('rules/security.md'),
96
37
  ])
97
- }
98
-
99
- /** Set up ~/.brainjar/ as an Obsidian vault */
100
- export async function initObsidian(brainjarDir: string) {
101
- const obsidianDir = join(brainjarDir, '.obsidian')
102
- const templatesDir = join(brainjarDir, 'templates')
103
38
 
104
- await mkdir(obsidianDir, { recursive: true })
105
- await mkdir(templatesDir, { recursive: true })
106
-
107
- await Promise.all([
108
- writeFile(join(obsidianDir, 'app.json'), obsidianAppConfigWithExclusions()),
109
- writeFile(join(obsidianDir, 'appearance.json'), obsidianAppearanceConfig()),
110
- writeFile(join(obsidianDir, 'core-plugins.json'), obsidianCorePlugins()),
111
- writeFile(join(obsidianDir, 'templates.json'), obsidianTemplatesConfig()),
112
- copySeed('templates/soul.md', join(templatesDir, 'soul.md')),
113
- copySeed('templates/persona.md', join(templatesDir, 'persona.md')),
114
- copySeed('templates/rule.md', join(templatesDir, 'rule.md')),
115
- ])
39
+ const engineerParsed = parseFrontmatter(engineerContent)
40
+ const plannerParsed = parseFrontmatter(plannerContent)
41
+ const reviewerParsed = parseFrontmatter(reviewerContent)
42
+
43
+ function extractBundledRules(fm: Record<string, unknown>): string[] {
44
+ return Array.isArray(fm.rules) ? fm.rules.map(String) : []
45
+ }
46
+
47
+ return {
48
+ souls: {
49
+ craftsman: { content: craftsmanContent },
50
+ },
51
+ personas: {
52
+ engineer: {
53
+ content: engineerParsed.body,
54
+ bundled_rules: extractBundledRules(engineerParsed.frontmatter),
55
+ },
56
+ planner: {
57
+ content: plannerParsed.body,
58
+ bundled_rules: extractBundledRules(plannerParsed.frontmatter),
59
+ },
60
+ reviewer: {
61
+ content: reviewerParsed.body,
62
+ bundled_rules: extractBundledRules(reviewerParsed.frontmatter),
63
+ },
64
+ },
65
+ rules: {
66
+ boundaries: { entries: [{ sort_key: 0, content: boundariesContent }] },
67
+ 'context-recovery': { entries: [{ sort_key: 0, content: contextRecoveryContent }] },
68
+ 'task-completion': { entries: [{ sort_key: 0, content: taskCompletionContent }] },
69
+ 'git-discipline': { entries: [{ sort_key: 0, content: gitDisciplineContent }] },
70
+ security: { entries: [{ sort_key: 0, content: securityContent }] },
71
+ },
72
+ }
116
73
  }
package/src/state.ts CHANGED
@@ -1,7 +1,5 @@
1
- import { readFile, writeFile, readdir, access, rename, mkdir, rm, stat } from 'node:fs/promises'
2
- import { join, basename } from 'node:path'
3
- import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
- import { getBrainjarDir, getLocalDir, paths } from './paths.js'
1
+ import type { BrainjarClient } from './client.js'
2
+ import type { ApiEffectiveState, ApiStateMutation } from './api-types.js'
5
3
 
6
4
  const SLUG_RE = /^[a-zA-Z0-9_-]+$/
7
5
 
@@ -16,399 +14,16 @@ export function normalizeSlug(value: string, label: string): string {
16
14
  return slug
17
15
  }
18
16
 
19
- /**
20
- * Resolve a single rule's content: validate slug, try directory (sorted .md files),
21
- * fall back to single .md file. Pushes warnings for invalid names, empty dirs, missing rules.
22
- * Returns an array of trimmed content strings.
23
- */
24
- export async function resolveRuleContent(rule: string, warnings: string[]): Promise<string[]> {
25
- let safe: string
26
- try {
27
- safe = normalizeSlug(rule, 'rule')
28
- } catch {
29
- warnings.push(`Rule "${rule}" has an invalid name — skipped`)
30
- return []
31
- }
32
-
33
- const rulePath = join(paths.rules, safe)
34
-
35
- // Try directory first
36
- try {
37
- const files = await readdir(rulePath)
38
- const mdFiles = files.filter(f => f.endsWith('.md')).sort()
39
- if (mdFiles.length === 0) {
40
- warnings.push(`Rule "${rule}" directory exists but contains no .md files`)
41
- }
42
- const contents: string[] = []
43
- for (const file of mdFiles) {
44
- const content = await readFile(join(rulePath, file), 'utf-8')
45
- contents.push(content.trim())
46
- }
47
- return contents
48
- } catch {
49
- // Not a directory — fall back to single .md file
50
- }
51
-
52
- try {
53
- const content = await readFile(`${rulePath}.md`, 'utf-8')
54
- return [content.trim()]
55
- } catch {}
56
-
57
- warnings.push(`Rule "${rule}" not found in ${paths.rules}`)
58
- return []
59
- }
60
-
61
- export async function listAvailableRules(): Promise<string[]> {
62
- const entries = await readdir(paths.rules, { withFileTypes: true }).catch(() => [])
63
- const available: string[] = []
64
- for (const entry of entries) {
65
- if (entry.isDirectory()) {
66
- available.push(entry.name)
67
- } else if (entry.name.endsWith('.md')) {
68
- available.push(basename(entry.name, '.md'))
69
- }
70
- }
71
- return available.sort()
72
- }
73
-
74
- export interface State {
75
- backend: string | null
76
- identity: string | null
77
- soul: string | null
78
- persona: string | null
79
- rules: string[]
80
- }
81
-
82
- const DEFAULT_STATE: State = { backend: null, identity: null, soul: null, persona: null, rules: [] }
83
-
84
- export async function requireBrainjarDir(): Promise<void> {
85
- try {
86
- await access(getBrainjarDir())
87
- } catch {
88
- throw new Error(`~/.brainjar/ not found. Run \`brainjar init\` first.`)
89
- }
90
- }
91
-
92
- export interface LayerFrontmatter {
93
- rules: string[]
94
- }
95
-
96
- export function parseLayerFrontmatter(content: string): LayerFrontmatter {
97
- const result: LayerFrontmatter = { rules: [] }
98
- const normalized = content.replace(/\r\n/g, '\n')
99
- const match = normalized.match(/^---\n([\s\S]*?)\n---/)
100
- if (!match) return result
101
-
102
- const parsed = parseYaml(match[1])
103
- if (parsed && typeof parsed === 'object') {
104
- if (Array.isArray(parsed.rules)) result.rules = parsed.rules.map(String)
105
- }
106
-
107
- return result
108
- }
109
-
110
- export function stripFrontmatter(content: string): string {
111
- return content.replace(/\r\n/g, '\n').replace(/^---\n[\s\S]*?\n---\n*/, '').trim()
112
- }
113
-
114
- export function parseIdentity(content: string) {
115
- const parsed = parseYaml(content)
116
- return {
117
- name: parsed?.name as string | undefined,
118
- email: parsed?.email as string | undefined,
119
- engine: parsed?.engine as string | undefined,
120
- }
121
- }
122
-
123
- export async function loadIdentity(slug: string) {
124
- const content = await readFile(join(paths.identities, `${slug}.yaml`), 'utf-8')
125
- return { slug, content, ...parseIdentity(content) }
126
- }
127
-
128
- /** Return a valid slug or null. Prevents path traversal from state.yaml. */
129
- function safeName(value: unknown): string | null {
130
- if (typeof value !== 'string' || !value) return null
131
- return SLUG_RE.test(value) ? value : null
132
- }
133
-
134
- export async function readState(): Promise<State> {
135
- let raw: string
136
- try {
137
- raw = await readFile(paths.state, 'utf-8')
138
- } catch (e) {
139
- if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { ...DEFAULT_STATE }
140
- throw new Error(`Could not read state.yaml: ${(e as Error).message}`)
141
- }
142
-
143
- let parsed: unknown
144
- try {
145
- parsed = parseYaml(raw)
146
- } catch (e) {
147
- throw new Error(`state.yaml is corrupt: ${(e as Error).message}`)
148
- }
149
-
150
- if (!parsed || typeof parsed !== 'object') return { ...DEFAULT_STATE }
151
-
152
- return {
153
- backend: ((parsed as any).backend === 'claude' || (parsed as any).backend === 'codex') ? (parsed as any).backend : null,
154
- identity: safeName((parsed as any).identity),
155
- soul: safeName((parsed as any).soul),
156
- persona: safeName((parsed as any).persona),
157
- rules: Array.isArray((parsed as any).rules)
158
- ? (parsed as any).rules.map(String).filter((r: string) => SLUG_RE.test(r))
159
- : [],
160
- }
161
- }
162
-
163
- const LOCK_TIMEOUT_MS = 5000
164
- const LOCK_STALE_MS = 10000
165
- const LOCK_POLL_MS = 50
166
-
167
- /**
168
- * Acquire an exclusive directory-based lock, run fn, then release.
169
- * Uses mkdir (atomic on all filesystems) as the lock primitive.
170
- * Stale locks older than 10s are automatically broken.
171
- */
172
- async function withLock<T>(lockDir: string, label: string, fn: () => Promise<T>): Promise<T> {
173
- const deadline = Date.now() + LOCK_TIMEOUT_MS
174
-
175
- while (true) {
176
- try {
177
- await mkdir(lockDir)
178
- break
179
- } catch (e) {
180
- if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
181
-
182
- // Break stale locks
183
- try {
184
- const info = await stat(lockDir)
185
- if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
186
- await rm(lockDir, { force: true, recursive: true })
187
- continue
188
- }
189
- } catch {}
190
-
191
- if (Date.now() > deadline) {
192
- throw new Error(`Could not acquire ${label} lock — another brainjar process may be running.`)
193
- }
194
- await new Promise(r => setTimeout(r, LOCK_POLL_MS))
195
- }
196
- }
197
-
198
- try {
199
- return await fn()
200
- } finally {
201
- await rm(lockDir, { force: true, recursive: true })
202
- }
203
- }
204
-
205
- export async function withStateLock<T>(fn: () => Promise<T>): Promise<T> {
206
- return withLock(`${paths.state}.lock`, 'state', fn)
207
- }
208
-
209
- export async function writeState(state: State): Promise<void> {
210
- const doc = {
211
- backend: state.backend ?? null,
212
- identity: state.identity ?? null,
213
- soul: state.soul ?? null,
214
- persona: state.persona ?? null,
215
- rules: state.rules,
216
- }
217
- const tmp = `${paths.state}.tmp`
218
- await writeFile(tmp, stringifyYaml(doc))
219
- await rename(tmp, paths.state)
220
- }
221
-
222
- // --- Local state ---
223
-
224
- /** Local state only stores overrides. undefined = cascade, null = explicit unset. */
225
- export interface LocalState {
226
- identity?: string | null
227
- soul?: string | null
228
- persona?: string | null
229
- rules?: {
230
- add?: string[]
231
- remove?: string[]
232
- }
233
- }
234
-
235
- /** Override state from env vars. Same shape as LocalState, read-only. */
236
- export type EnvState = LocalState
237
-
238
- export type Scope = 'global' | 'local' | '+local' | '-local' | 'env' | '+env' | '-env'
239
-
240
- /** Effective state after merging global + local + env, with scope annotations. */
241
- export interface EffectiveState {
242
- backend: string | null
243
- identity: { value: string | null; scope: Scope }
244
- soul: { value: string | null; scope: Scope }
245
- persona: { value: string | null; scope: Scope }
246
- rules: { value: string; scope: Scope }[]
247
- }
248
-
249
- export async function readLocalState(): Promise<LocalState> {
250
- let raw: string
251
- try {
252
- raw = await readFile(paths.localState, 'utf-8')
253
- } catch (e) {
254
- if ((e as NodeJS.ErrnoException).code === 'ENOENT') return {}
255
- throw new Error(`Could not read local state.yaml: ${(e as Error).message}`)
256
- }
257
-
258
- let parsed: unknown
259
- try {
260
- parsed = parseYaml(raw)
261
- } catch (e) {
262
- throw new Error(`Local state.yaml is corrupt: ${(e as Error).message}`)
263
- }
264
-
265
- if (!parsed || typeof parsed !== 'object') return {}
266
-
267
- const result: LocalState = {}
268
- const p = parsed as Record<string, unknown>
269
-
270
- // For each layer: if key is present, include it (even if null)
271
- if ('identity' in p) result.identity = p.identity === null ? null : safeName(p.identity)
272
- if ('soul' in p) result.soul = p.soul === null ? null : safeName(p.soul)
273
- if ('persona' in p) result.persona = p.persona === null ? null : safeName(p.persona)
274
-
275
- if (p.rules && typeof p.rules === 'object') {
276
- const r = p.rules as Record<string, unknown>
277
- result.rules = {}
278
- if (Array.isArray(r.add)) {
279
- result.rules.add = r.add.map(String).filter((s: string) => SLUG_RE.test(s))
280
- }
281
- if (Array.isArray(r.remove)) {
282
- result.rules.remove = r.remove.map(String).filter((s: string) => SLUG_RE.test(s))
283
- }
284
- }
285
-
286
- return result
287
- }
288
-
289
- export async function writeLocalState(local: LocalState): Promise<void> {
290
- const localDir = getLocalDir()
291
- await mkdir(localDir, { recursive: true })
292
-
293
- // Build a clean doc — only include keys that are present in local
294
- const doc: Record<string, unknown> = {}
295
- if ('identity' in local) doc.identity = local.identity ?? null
296
- if ('soul' in local) doc.soul = local.soul ?? null
297
- if ('persona' in local) doc.persona = local.persona ?? null
298
- if (local.rules) {
299
- const rules: Record<string, string[]> = {}
300
- if (local.rules.add?.length) rules.add = local.rules.add
301
- if (local.rules.remove?.length) rules.remove = local.rules.remove
302
- if (Object.keys(rules).length) doc.rules = rules
303
- }
304
-
305
- const tmp = `${paths.localState}.tmp`
306
- await writeFile(tmp, stringifyYaml(doc))
307
- await rename(tmp, paths.localState)
17
+ /** Fetch the fully resolved effective state from the server. */
18
+ export async function getEffectiveState(api: BrainjarClient): Promise<ApiEffectiveState> {
19
+ return api.get<ApiEffectiveState>('/api/v1/state')
308
20
  }
309
21
 
310
- /** Read override state from BRAINJAR_* env vars. Pure, no I/O.
311
- * If extraEnv is provided, those values take precedence over process.env. */
312
- export function readEnvState(extraEnv?: Record<string, string>): EnvState {
313
- const env = extraEnv ? { ...process.env, ...extraEnv } : process.env
314
- const result: EnvState = {}
315
-
316
- if (env.BRAINJAR_IDENTITY !== undefined) {
317
- result.identity = env.BRAINJAR_IDENTITY === '' ? null : safeName(env.BRAINJAR_IDENTITY)
318
- }
319
- if (env.BRAINJAR_SOUL !== undefined) {
320
- result.soul = env.BRAINJAR_SOUL === '' ? null : safeName(env.BRAINJAR_SOUL)
321
- }
322
- if (env.BRAINJAR_PERSONA !== undefined) {
323
- result.persona = env.BRAINJAR_PERSONA === '' ? null : safeName(env.BRAINJAR_PERSONA)
324
- }
325
-
326
- const addRaw = env.BRAINJAR_RULES_ADD
327
- const removeRaw = env.BRAINJAR_RULES_REMOVE
328
- if (addRaw !== undefined || removeRaw !== undefined) {
329
- result.rules = {}
330
- if (addRaw) {
331
- result.rules.add = addRaw.split(',').map(s => s.trim()).filter(s => SLUG_RE.test(s))
332
- }
333
- if (removeRaw) {
334
- result.rules.remove = removeRaw.split(',').map(s => s.trim()).filter(s => SLUG_RE.test(s))
335
- }
336
- }
337
-
338
- return result
339
- }
340
-
341
- export async function withLocalStateLock<T>(fn: () => Promise<T>): Promise<T> {
342
- const localDir = getLocalDir()
343
- await mkdir(localDir, { recursive: true })
344
- return withLock(`${paths.localState}.lock`, 'local state', fn)
345
- }
346
-
347
- /** Apply overrides from a given scope onto an existing effective state. */
348
- function applyOverrides(
349
- base: EffectiveState,
350
- overrides: LocalState | EnvState,
351
- scope: 'local' | 'env',
352
- ): EffectiveState {
353
- const plusScope = `+${scope}` as Scope
354
- const minusScope = `-${scope}` as Scope
355
-
356
- const identity = 'identity' in overrides
357
- ? { value: overrides.identity ?? null, scope: scope as Scope }
358
- : base.identity
359
-
360
- const soul = 'soul' in overrides
361
- ? { value: overrides.soul ?? null, scope: scope as Scope }
362
- : base.soul
363
-
364
- const persona = 'persona' in overrides
365
- ? { value: overrides.persona ?? null, scope: scope as Scope }
366
- : base.persona
367
-
368
- // Rules: take active rules from base, apply adds/removes
369
- const adds = new Set(overrides.rules?.add ?? [])
370
- const removes = new Set(overrides.rules?.remove ?? [])
371
-
372
- const rules: EffectiveState['rules'] = []
373
- const seen = new Set<string>()
374
-
375
- // Process existing rules (keep active ones, mark newly removed)
376
- for (const r of base.rules) {
377
- if (r.scope.startsWith('-')) {
378
- // Already removed by a lower scope — keep the removal marker
379
- rules.push(r)
380
- seen.add(r.value)
381
- continue
382
- }
383
- if (removes.has(r.value)) {
384
- rules.push({ value: r.value, scope: minusScope })
385
- } else {
386
- rules.push(r)
387
- }
388
- seen.add(r.value)
389
- }
390
-
391
- // Add new rules from this scope (that aren't already present)
392
- for (const r of adds) {
393
- if (!seen.has(r)) {
394
- rules.push({ value: r, scope: plusScope })
395
- }
396
- }
397
-
398
- return { backend: base.backend, identity, soul, persona, rules }
399
- }
400
-
401
- /** Pure merge: global → local → env, each scope overrides the previous. */
402
- export function mergeState(global: State, local: LocalState, env?: EnvState): EffectiveState {
403
- // Start with global as the base effective state
404
- const base: EffectiveState = {
405
- backend: global.backend,
406
- identity: { value: global.identity, scope: 'global' },
407
- soul: { value: global.soul, scope: 'global' },
408
- persona: { value: global.persona, scope: 'global' },
409
- rules: global.rules.map(r => ({ value: r, scope: 'global' as Scope })),
410
- }
411
-
412
- const withLocal = applyOverrides(base, local, 'local')
413
- return env ? applyOverrides(withLocal, env, 'env') : withLocal
22
+ /** Mutate state on the server. Pass options.project to scope the mutation to a project. */
23
+ export async function putState(
24
+ api: BrainjarClient,
25
+ body: ApiStateMutation,
26
+ options?: { project?: string },
27
+ ): Promise<void> {
28
+ await api.put<void>('/api/v1/state', body, options?.project ? { project: options.project } : undefined)
414
29
  }