@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.
- package/README.md +11 -10
- package/package.json +2 -2
- package/src/api-types.ts +155 -0
- package/src/cli.ts +5 -3
- package/src/client.ts +157 -0
- package/src/commands/brain.ts +99 -113
- package/src/commands/compose.ts +17 -116
- package/src/commands/init.ts +66 -42
- package/src/commands/migrate.ts +61 -0
- package/src/commands/pack.ts +1 -5
- package/src/commands/persona.ts +97 -145
- package/src/commands/rules.ts +71 -174
- package/src/commands/server.ts +212 -0
- package/src/commands/shell.ts +55 -51
- package/src/commands/soul.ts +75 -110
- package/src/commands/status.ts +37 -78
- package/src/commands/sync.ts +0 -2
- package/src/config.ts +125 -0
- package/src/daemon.ts +404 -0
- package/src/errors.ts +172 -0
- package/src/migrate.ts +247 -0
- package/src/pack.ts +149 -428
- package/src/paths.ts +1 -8
- package/src/seeds.ts +62 -105
- package/src/state.ts +12 -397
- package/src/sync.ts +61 -102
- package/src/version-check.ts +137 -0
- package/src/brain.ts +0 -69
- package/src/commands/identity.ts +0 -276
- package/src/engines/bitwarden.ts +0 -105
- package/src/engines/index.ts +0 -12
- package/src/engines/types.ts +0 -12
- package/src/hooks.test.ts +0 -132
- package/src/pack.test.ts +0 -472
- package/src/seeds/templates/persona.md +0 -19
- package/src/seeds/templates/rule.md +0 -11
- package/src/seeds/templates/soul.md +0 -20
- /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
- /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
- /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
|
|
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 {
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
*
|
|
20
|
-
*
|
|
13
|
+
* Build a ContentBundle from the embedded seed files.
|
|
14
|
+
* This bundle can be POSTed to /api/v1/import.
|
|
21
15
|
*/
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
/**
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
}
|