Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions hub/src/overseer/brainClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { filterChatModels, listBrainProfiles, resolveBrainConfig } from './brainClient'
import { filterChatModels, isKnownBrainProfile, listBrainProfiles, resolveBrainConfig, resolveBrainSelection } from './brainClient'

const baseEnv = {
OVERSEER_BRAIN_URL: 'http://local.test/v1/',
Expand Down Expand Up @@ -32,15 +32,49 @@ describe('resolveBrainConfig', () => {
expect(cfg).toMatchObject({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o', apiKey: 'sk-test' })
})

it('falls back to default for an unknown profile', () => {
expect(resolveBrainConfig(multiEnv, { profile: 'nope' })?.baseUrl).toBe('http://local.test/v1')
it('returns null for an unknown named profile (no silent fallback to env default)', () => {
expect(resolveBrainConfig(multiEnv, { profile: 'nope' })).toBeNull()
})

it('uses only the env default when profile is explicitly default', () => {
const cfg = resolveBrainConfig(multiEnv, { profile: 'default' })
expect(cfg).toMatchObject({ baseUrl: 'http://local.test/v1', model: 'main' })
})

it('model override wins over the selected profile model', () => {
expect(resolveBrainConfig(multiEnv, { profile: 'openai', model: 'gpt-4o-mini' })?.model).toBe('gpt-4o-mini')
})
})

describe('resolveBrainSelection', () => {
it('falls through to env default when nothing is set', () => {
expect(resolveBrainSelection(null)).toEqual({ model: undefined })
})

it('uses the persisted active brain when no per-request override', () => {
expect(resolveBrainSelection({ profile: 'openai', model: 'gpt-4o' })).toEqual({ profile: 'openai', model: 'gpt-4o' })
})

it('per-request profile overrides the active profile wholesale', () => {
expect(resolveBrainSelection({ profile: 'openai', model: 'gpt-4o' }, { profile: 'default' }))
.toEqual({ profile: 'default', model: undefined })
})

it('per-request model alone re-skins the active profile', () => {
expect(resolveBrainSelection({ profile: 'openai', model: 'gpt-4o' }, { model: 'gpt-4o-mini' }))
.toEqual({ profile: 'openai', model: 'gpt-4o-mini' })
})

it('active profile with null model resolves to profile default', () => {
expect(resolveBrainSelection({ profile: 'local', model: null })).toEqual({ profile: 'local', model: undefined })
})

it('composes with resolveBrainConfig so the active brain becomes the effective config', () => {
const cfg = resolveBrainConfig(multiEnv, resolveBrainSelection({ profile: 'openai', model: 'gpt-4o-mini' }))
expect(cfg).toMatchObject({ baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini', apiKey: 'sk-test' })
})
})

describe('listBrainProfiles', () => {
it('lists the default plus named profiles (no url/key exposed)', () => {
const list = listBrainProfiles(multiEnv)
Expand All @@ -55,6 +89,14 @@ describe('listBrainProfiles', () => {
})
})

describe('isKnownBrainProfile', () => {
it('returns true for configured profile ids and false otherwise', () => {
expect(isKnownBrainProfile('default', multiEnv)).toBe(true)
expect(isKnownBrainProfile('openai', multiEnv)).toBe(true)
expect(isKnownBrainProfile('ghost', multiEnv)).toBe(false)
})
})

describe('filterChatModels', () => {
it('drops non-chat models and sorts the rest', () => {
const filtered = filterChatModels([
Expand Down
37 changes: 35 additions & 2 deletions hub/src/overseer/brainClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,45 @@ export function resolveBrainConfig(
env: NodeJS.ProcessEnv = process.env,
opts: { profile?: string; model?: string } = {}
): BrainConfig | null {
let cfg: BrainConfig | null = null
const profile = opts.profile?.trim()
let cfg: BrainConfig | null = null
if (profile && profile.toLowerCase() !== 'default') {
cfg = readProfile(env, `OVERSEER_BRAIN_PROFILE_${profile.toUpperCase()}_`)
// Named profile requested but not configured — do not silently fall back to env default.
if (!cfg) return null
} else {
cfg = readProfile(env, 'OVERSEER_BRAIN_')
}
if (!cfg) cfg = readProfile(env, 'OVERSEER_BRAIN_')
if (!cfg) return null
const model = opts.model?.trim()
return model ? { ...cfg, model } : cfg
}

/**
* Collapse the three brain-selection layers into a single `{ profile, model }` to hand
* to `resolveBrainConfig`. Precedence, highest first:
* 1. per-request override (converse body `profile`/`model`) — testing "at whim"
* 2. persisted active brain (operator's console choice, survives restart)
* 3. env default (falls through as no profile/model)
*
* An explicit per-request `profile` overrides the active profile wholesale (its own optional
* model, not the active profile's model). A per-request `model` alone re-skins the active profile.
*/
export function resolveBrainSelection(
active: { profile: string; model: string | null } | null,
opts: { profile?: string; model?: string } = {}
): { profile?: string; model?: string } {
const reqProfile = opts.profile?.trim()
const reqModel = opts.model?.trim()
if (reqProfile) {
return { profile: reqProfile, model: reqModel || undefined }
}
if (active) {
return { profile: active.profile, model: reqModel || active.model || undefined }
}
return { model: reqModel || undefined }
}

const NON_CHAT_MODEL = /embedding|whisper|tts|dall-?e|moderation|audio|realtime|image|transcribe|-search|babbage|davinci-002|instruct/i

/** Keep the chat-usable model ids (drop embeddings/audio/image/etc.), sorted. */
Expand Down Expand Up @@ -137,6 +165,11 @@ export async function listBrainModels(config: BrainConfig, timeoutMs = 12_000):
.filter((id): id is string => typeof id === 'string' && id.length > 0)
}

/** True when `profile` is a brain the hub currently has configured in env. */
export function isKnownBrainProfile(profile: string, env: NodeJS.ProcessEnv = process.env): boolean {
return listBrainProfiles(env).some((p) => p.id === profile)
}

/** List configured brain profiles for the UI (id/label/model only — no url/key). */
export function listBrainProfiles(env: NodeJS.ProcessEnv = process.env): OverseerBrainProfileInfo[] {
const out: OverseerBrainProfileInfo[] = []
Expand Down
7 changes: 7 additions & 0 deletions hub/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { SessionStore } from './sessionStore'
import { UserStore } from './userStore'
import { EventStore } from './eventStore'
import { InboxStore } from './inboxStore'
import { SettingsStore, ensureOverseerSettingsSchema } from './settingsStore'
import { ensureOverseerEventsSchema, ensureDeletedSessionsSchema } from './events'
import { ensureOverseerInboxSchema } from './inboxItems'

Expand All @@ -28,6 +29,8 @@ export { SessionStore } from './sessionStore'
export { UserStore } from './userStore'
export { EventStore } from './eventStore'
export { InboxStore } from './inboxStore'
export { SettingsStore } from './settingsStore'
export type { ActiveBrainSetting } from './settingsStore'
export type { InsertSystemEventInput, ListSystemEventsOptions, StoredSystemEvent } from './eventStore'
export type { ListInboxItemsOptions, StoredInboxItem } from './inboxStore'

Expand All @@ -44,6 +47,7 @@ const REQUIRED_TABLES = [
'inbox_items',
'inbox_item_source_events',
'inbox_operator_actions',
'overseer_settings',
] as const

export class Store {
Expand All @@ -58,6 +62,7 @@ export class Store {
readonly push: PushStore
readonly events: EventStore
readonly inbox: InboxStore
readonly settings: SettingsStore

/**
* Filesystem path of the underlying SQLite database, or ':memory:' for
Expand Down Expand Up @@ -110,6 +115,7 @@ export class Store {
this.push = new PushStore(this.db)
this.events = new EventStore(this.db)
this.inbox = new InboxStore(this.db)
this.settings = new SettingsStore(this.db)
}

close(): void {
Expand Down Expand Up @@ -195,6 +201,7 @@ export class Store {
ensureOverseerEventsSchema(this.db)
ensureDeletedSessionsSchema(this.db)
ensureOverseerInboxSchema(this.db)
ensureOverseerSettingsSchema(this.db)
this.assertRequiredTablesPresent()
}

Expand Down
54 changes: 54 additions & 0 deletions hub/src/store/settingsStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { Database } from 'bun:sqlite'
import { SettingsStore, ensureOverseerSettingsSchema } from './settingsStore'

function freshStore(): SettingsStore {
const db = new Database(':memory:', { strict: true })
ensureOverseerSettingsSchema(db)
return new SettingsStore(db)
}

describe('SettingsStore', () => {
it('round-trips a raw key/value', () => {
const s = freshStore()
expect(s.get('missing')).toBeNull()
s.set('k', 'v')
expect(s.get('k')).toBe('v')
s.set('k', 'v2')
expect(s.get('k')).toBe('v2')
s.delete('k')
expect(s.get('k')).toBeNull()
})

it('round-trips the active brain (profile + model)', () => {
const s = freshStore()
expect(s.getActiveBrain()).toBeNull()
s.setActiveBrain({ profile: 'openai', model: 'gpt-4o' })
expect(s.getActiveBrain()).toEqual({ profile: 'openai', model: 'gpt-4o' })
})

it('normalizes a null model and clears', () => {
const s = freshStore()
s.setActiveBrain({ profile: 'local', model: null })
expect(s.getActiveBrain()).toEqual({ profile: 'local', model: null })
s.clearActiveBrain()
expect(s.getActiveBrain()).toBeNull()
})

it('returns null on malformed persisted json rather than throwing', () => {
const s = freshStore()
s.set('active_brain', 'not json{')
expect(s.getActiveBrain()).toBeNull()
s.set('active_brain', JSON.stringify({ model: 'x' }))
expect(s.getActiveBrain()).toBeNull()
})

it('DDL is idempotent', () => {
const db = new Database(':memory:', { strict: true })
ensureOverseerSettingsSchema(db)
ensureOverseerSettingsSchema(db)
const store = new SettingsStore(db)
store.set('a', 'b')
expect(store.get('a')).toBe('b')
})
})
70 changes: 70 additions & 0 deletions hub/src/store/settingsStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Database } from 'bun:sqlite'

/**
* Tiny key/value settings table for hub-side runtime config that must survive a restart and be
* switchable at whim without editing env + bouncing the hub. First consumer: the Overseer's
* active brain (which profile/model the converse + voice surfaces default to). Idempotent DDL,
* run on every boot alongside the other Overseer self-heal schemas (not on the SCHEMA_VERSION ladder).
*/
export function ensureOverseerSettingsSchema(db: Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS overseer_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`)
}

/** The persisted active-brain selection — a profile id plus an optional model override. */
export type ActiveBrainSetting = {
profile: string
model: string | null
}

const ACTIVE_BRAIN_KEY = 'active_brain'

export class SettingsStore {
constructor(private readonly db: Database) {}

get(key: string): string | null {
const row = this.db.prepare('SELECT value FROM overseer_settings WHERE key = ?').get(key) as
| { value: string }
| undefined
return row?.value ?? null
}

set(key: string, value: string): void {
this.db
.prepare(
`INSERT INTO overseer_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
)
.run(key, value, Date.now())
}

delete(key: string): void {
this.db.prepare('DELETE FROM overseer_settings WHERE key = ?').run(key)
}

/** Read the persisted active brain, or null when the operator has never chosen one (use env default). */
getActiveBrain(): ActiveBrainSetting | null {
const raw = this.get(ACTIVE_BRAIN_KEY)
if (!raw) return null
try {
const parsed = JSON.parse(raw) as Partial<ActiveBrainSetting>
if (typeof parsed.profile !== 'string' || parsed.profile.length === 0) return null
return { profile: parsed.profile, model: typeof parsed.model === 'string' ? parsed.model : null }
} catch {
return null
}
}

setActiveBrain(value: ActiveBrainSetting): void {
this.set(ACTIVE_BRAIN_KEY, JSON.stringify({ profile: value.profile, model: value.model ?? null }))
}

clearActiveBrain(): void {
this.delete(ACTIVE_BRAIN_KEY)
}
}
6 changes: 6 additions & 0 deletions hub/src/sync/syncEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { OverseerEntity } from './overseerEntity'
import { extractAssistantPlainText } from '@hapi/protocol/messages'
import type { InboxOperatorAction } from '@hapi/protocol'
import type { ListSystemEventsOptions, StoredSystemEvent } from '../store'
import type { SettingsStore } from '../store'
import type { ListInboxItemsOptions, StoredInboxItem } from '../store/inboxItems'

export type { Session, SyncEvent } from '@hapi/protocol/types'
Expand Down Expand Up @@ -340,6 +341,11 @@ export class SyncEngine {
return this.overseer
}

/** Hub settings KV (persisted active brain, etc.) — see SettingsStore. */
getSettings(): SettingsStore {
return this.store.settings
}

getSystemEvents(options: ListSystemEventsOptions = {}): StoredSystemEvent[] {
return this.overseerEvents.list(options)
}
Expand Down
Loading
Loading