Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
2 changes: 1 addition & 1 deletion src/agent/infra/agent/cipher-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ import type {ToolProvider} from '../tools/tool-provider.js'
import type {AgentConfig} from './agent-schemas.js'
import type {ProviderUpdateConfig} from './provider-update-config.js'

import {SETTINGS_KEYS} from '../../../server/core/domain/entities/settings.js'
import {TransportStateEventNames} from '../../../server/core/domain/transport/schemas.js'
import {agentLog} from '../../../server/utils/process-logger.js'
import {SETTINGS_KEYS} from '../../../shared/types/settings-keys.js'
import {getEffectiveMaxInputTokens, resolveRegistryProvider} from '../../core/domain/llm/index.js'
import {STREAMING_EVENT_NAMES} from '../../core/domain/streaming/types.js'
import {ToolName} from '../../core/domain/tools/constants.js'
Expand Down
9 changes: 9 additions & 0 deletions src/oclif/commands/config/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ export default class ConfigSet extends Command {
const {args, flags} = await this.parse(ConfigSet)
const format = flags.format as 'json' | 'text'

if (args.key === 'language.mode' || args.key === 'language.code') {
this.fail(
format,
'deprecated-key',
`'${args.key}' has moved to global settings. Run: brv settings set ${args.key} ${args.value}`,
)
Comment thread
RyanNg1403 marked this conversation as resolved.
return
}

const projectRoot = resolveProjectRoot()
const store = new ProjectConfigStore()
const current = await store.read(projectRoot)
Expand Down
53 changes: 48 additions & 5 deletions src/oclif/commands/curate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {Args, Command, Flags} from '@oclif/core'
import type {BrvConfigLanguage} from '../../../server/core/domain/entities/brv-config.js'

import {ProjectConfigStore} from '../../../server/infra/config/file-config-store.js'
import {SettingsEvents, type SettingsListResponse} from '../../../shared/transport/events/settings-events.js'
import {SETTINGS_KEYS} from '../../../shared/types/settings-keys.js'
import {continueSession, kickoffSession, resolveProjectRoot} from '../../lib/curate-session.js'
import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js'
import {writeJsonResponse} from '../../lib/json-response.js'
Expand Down Expand Up @@ -262,13 +264,25 @@ Bad examples:
}

/**
* Read the per-project language preference from `.brv/config.json`.
* Missing config (fresh project) or missing field returns `undefined`,
* which the kickoff / correction prompts treat as the auto clause —
* match the user's input language. Read failures degrade silently to
* `undefined` so a corrupt config never blocks curate.
* Resolve the language preference. Daemon settings (the source of
* truth) take precedence; a per-project `.brv/config.json language`
* field acts as a fallback for users who configured language before
* it moved to global settings.
*
* Note on precedence: only daemon `mode: 'fixed'` short-circuits the
* fallback. An explicit daemon `mode: 'auto'` reads as "no opinion"
* and falls through to project config, so a stale project-config
* `fixed/X` will still win. This is intentional for the migration
* window — distinguishing "user explicitly chose auto" from "user
* never touched settings" needs raw-overrides access that the
* transport doesn't expose today, and the bug only manifests for
* users with a pre-existing per-project `language` field. Revisit
* once project-config language is fully sunset.
*/
Comment thread
RyanNg1403 marked this conversation as resolved.
private async resolveLanguagePreference(projectRoot: string): Promise<BrvConfigLanguage | undefined> {
const fromSettings = await readLanguageFromSettings()
if (fromSettings !== undefined) return fromSettings

try {
const config = await new ProjectConfigStore().read(projectRoot)
return config?.language
Expand All @@ -277,3 +291,32 @@ Bad examples:
}
}
}

/**
* Reads the language preference from daemon settings via the same
* `SettingsEvents.LIST` transport every other settings consumer uses.
*
* Exported (and accepts a `DaemonClientOptions`) so tests can drive
* `withDaemonRetry` with a stubbed transport client. Returns `undefined`
* on any non-fixed mode, missing/non-string code, or daemon error —
* callers should treat `undefined` as "no opinion" and fall back to
* project config / the auto clause.
*/
export async function readLanguageFromSettings(
options?: DaemonClientOptions,
): Promise<BrvConfigLanguage | undefined> {
try {
const response = await withDaemonRetry<SettingsListResponse>(
Comment thread
RyanNg1403 marked this conversation as resolved.
async (client) => client.requestWithAck<SettingsListResponse>(SettingsEvents.LIST),
options,
)
const byKey = new Map(response.items.map((item) => [item.key, item.current]))
const mode = byKey.get(SETTINGS_KEYS.LANGUAGE_MODE)
const code = byKey.get(SETTINGS_KEYS.LANGUAGE_CODE)
if (mode !== 'fixed') return undefined
if (typeof code !== 'string') return undefined
return {code, mode: 'fixed'}
} catch {
return undefined
}
}
3 changes: 2 additions & 1 deletion src/oclif/commands/settings/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ export default class SettingsGet extends Command {
}
}

function renderValue(item: SettingsItemDTO, value: boolean | number): string {
function renderValue(item: SettingsItemDTO, value: boolean | number | string): string {
if (typeof value === 'boolean') return value ? 'true' : 'false'
Comment thread
RyanNg1403 marked this conversation as resolved.
if (typeof value === 'string') return value
return renderInteger(item, value)
}

Expand Down
35 changes: 11 additions & 24 deletions src/oclif/commands/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,16 @@ import {
type SettingsItemDTO,
type SettingsListResponse,
} from '../../../shared/transport/events/settings-events.js'
import {
CATEGORY_HEADERS,
CATEGORY_ORDER,
type SettingsRowCategory,
toRowCategory,
} from '../../../shared/types/settings-row.js'
import {formatCount, formatDuration} from '../../../shared/utils/format-duration.js'
import {type DaemonClientOptions, formatConnectionError, withDaemonRetry} from '../../lib/daemon-client.js'
import {writeJsonResponse} from '../../lib/json-response.js'

type CategoryName = 'concurrency' | 'llm' | 'task-history' | 'updates'

const CATEGORY_ORDER: readonly CategoryName[] = ['concurrency', 'llm', 'task-history', 'updates']

const CATEGORY_HEADERS: Readonly<Record<CategoryName, string>> = {
concurrency: 'CONCURRENCY',
llm: 'LLM',
'task-history': 'TASK HISTORY',
updates: 'UPDATES',
}

const OTHER_HEADER = 'OTHER'

export default class Settings extends Command {
public static description =
'List user-configurable BRV settings. Changes apply after `brv restart`.'
Expand Down Expand Up @@ -83,22 +76,15 @@ export default class Settings extends Command {
this.log('')
}

const otherRows = byCategory.get('__other__')
if (otherRows && otherRows.length > 0) {
this.log(OTHER_HEADER)
for (const row of otherRows) this.log(formatRow(row))
this.log('')
}

this.log('Set: brv settings set <key> <value>')
this.log('Reset: brv settings reset <key>')
}
}

function groupByCategory(items: readonly SettingsItemDTO[]): Map<string, SettingsItemDTO[]> {
const map = new Map<string, SettingsItemDTO[]>()
function groupByCategory(items: readonly SettingsItemDTO[]): Map<SettingsRowCategory, SettingsItemDTO[]> {
const map = new Map<SettingsRowCategory, SettingsItemDTO[]>()
for (const item of items) {
const bucket = item.category ?? '__other__'
const bucket: SettingsRowCategory = toRowCategory(item.category)
const list = map.get(bucket) ?? []
list.push(item)
map.set(bucket, list)
Expand All @@ -114,8 +100,9 @@ function formatRow(item: SettingsItemDTO): string {
return ` ${pad(item.key, 40)} ${pad(current, 7)} (default ${defaultStr})${''.padEnd(Math.max(0, 8 - defaultStr.length))} ${range}`
}

function renderValue(item: SettingsItemDTO, value: boolean | number): string {
function renderValue(item: SettingsItemDTO, value: boolean | number | string): string {
if (typeof value === 'boolean') return value ? 'true' : 'false'
if (typeof value === 'string') return value
return renderInteger(item, value)
}

Expand Down
3 changes: 2 additions & 1 deletion src/oclif/commands/settings/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ export default class SettingsReset extends Command {
}
}

function renderValue(item: SettingsItemDTO, value: boolean | number): string {
function renderValue(item: SettingsItemDTO, value: boolean | number | string): string {
if (typeof value === 'boolean') return value ? 'true' : 'false'
if (typeof value === 'string') return value
if (item.unit === 'ms') return formatDuration(value)
return formatCount(value)
}
18 changes: 16 additions & 2 deletions src/oclif/commands/settings/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export default class SettingsSet extends Command {

protected async writeSetting(
key: string,
value: boolean | number,
value: boolean | number | string,
options?: DaemonClientOptions,
): Promise<SettingsSetResponse> {
return withDaemonRetry<SettingsSetResponse>(
Expand All @@ -131,7 +131,7 @@ export default class SettingsSet extends Command {
}

type ParseResult =
| {readonly display: string; readonly kind: 'ok'; readonly value: boolean | number}
| {readonly display: string; readonly kind: 'ok'; readonly value: boolean | number | string}
| {readonly kind: 'error'; readonly message: string}

const BOOLEAN_TOKENS = new Map<string, boolean>([
Expand All @@ -149,10 +149,24 @@ const BOOLEAN_TOKENS_HINT = 'true, false, on, off, 1, 0, yes, no'

function parseValue(descriptor: SettingsItemDTO, raw: string): ParseResult {
if (descriptor.type === 'boolean') return parseAsBoolean(descriptor, raw)
if (descriptor.type === 'enum') return parseAsEnum(descriptor, raw)
if (descriptor.unit === 'ms') return parseAsDuration(descriptor, raw)
return parseAsCount(descriptor, raw)
}

function parseAsEnum(descriptor: SettingsItemDTO, raw: string): ParseResult {
const trimmed = raw.trim()
const options = descriptor.options ?? []
if (!options.includes(trimmed)) {
return {
kind: 'error',
message: `${descriptor.key} expected one of [${options.join(', ')}], got '${raw}'.`,
}
}

return {display: trimmed, kind: 'ok', value: trimmed}
}

function parseAsBoolean(descriptor: SettingsItemDTO, raw: string): ParseResult {
const lowered = raw.trim().toLowerCase()
const value = BOOLEAN_TOKENS.get(lowered)
Expand Down
50 changes: 31 additions & 19 deletions src/server/core/domain/entities/settings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {LANGUAGE_NAMES} from '../../../../shared/language/language-names.js'
import {SETTINGS_KEYS} from '../../../../shared/types/settings-keys.js'
import {
AGENT_LLM_ITERATION_BUDGET_MS,
AGENT_LLM_REQUEST_TIMEOUT_MS,
Expand All @@ -12,7 +14,7 @@ import {
* and TUI render output (uppercased). Web docs / WebUI consume this
* field to render the same groupings independently of key naming.
*/
export type SettingCategory = 'concurrency' | 'llm' | 'task-history' | 'updates'
export type SettingCategory = 'concurrency' | 'language' | 'llm' | 'task-history' | 'updates'

/**
* Value-kind for dispatch between the duration formatter / parser
Expand Down Expand Up @@ -48,42 +50,34 @@ export type BooleanSettingDescriptor = BaseSettingDescriptor & {
readonly type: 'boolean'
}

export type EnumSettingDescriptor = BaseSettingDescriptor & {
readonly default: string
readonly options: readonly string[]
readonly type: 'enum'
}

/**
* Descriptor for a single user-configurable setting. Discriminated on
* `type` so consumers narrow with a single check before reading
* type-specific fields (`min`/`max` on integers, etc).
* type-specific fields (`min`/`max` on integers, `options` on enums, etc).
*
* Defaults reference the existing constants module so a constant change
* automatically updates the setting's default.
*/
export type SettingDescriptor = BooleanSettingDescriptor | IntegerSettingDescriptor
export type SettingDescriptor = BooleanSettingDescriptor | EnumSettingDescriptor | IntegerSettingDescriptor

/**
* View of one setting: the key, the user's current override (or the default
* if none is set), and the registered default. Carries the union of value
* shapes; consumers narrow on the corresponding descriptor's `type`.
*/
export type SettingItem = {
readonly current: boolean | number
readonly default: boolean | number
readonly current: boolean | number | string
readonly default: boolean | number | string
readonly key: string
readonly restartRequired: boolean
}

/**
* Single source of truth for setting key names. Importers must reference
* these constants instead of inline string literals so a rename of one
* key is a typecheck error at every call site (validator, bootstrap,
* agent snapshot read, CLI tests).
*/
export const SETTINGS_KEYS = {
AGENT_POOL_MAX_CONCURRENT_TASKS: 'agentPool.maxConcurrentTasksPerProject',
AGENT_POOL_MAX_SIZE: 'agentPool.maxSize',
LLM_ITERATION_BUDGET_MS: 'llm.iterationBudgetMs',
LLM_REQUEST_TIMEOUT_MS: 'llm.requestTimeoutMs',
TASK_HISTORY_MAX_ENTRIES: 'taskHistory.maxEntries',
UPDATE_CHECK_FOR_UPDATES: 'update.checkForUpdates',
} as const

export const SETTINGS_REGISTRY: readonly SettingDescriptor[] = [
{
Expand Down Expand Up @@ -146,6 +140,24 @@ export const SETTINGS_REGISTRY: readonly SettingDescriptor[] = [
restartRequired: false,
type: 'boolean',
},
{
category: 'language',
default: 'auto',
description: 'Match input language (auto) or force a fixed language for written output',
key: SETTINGS_KEYS.LANGUAGE_MODE,
options: ['auto', 'fixed'],
restartRequired: false,
type: 'enum',
},
{
category: 'language',
default: 'en',
description: 'ISO-639-1 code applied when mode is fixed; ignored in auto mode',
key: SETTINGS_KEYS.LANGUAGE_CODE,
options: Object.keys(LANGUAGE_NAMES),
restartRequired: false,
type: 'enum',
},
]

export function findSettingDescriptor(key: string): SettingDescriptor | undefined {
Expand Down
37 changes: 4 additions & 33 deletions src/server/core/domain/render/language-clause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,38 +16,9 @@

import type {BrvConfigLanguage} from '../entities/brv-config.js'

/**
* ISO-639-1 code → English language name. Inline (~24 entries) rather than
* pulling the `iso-639-1` package — runtime dependency surface stays
* minimal. Codes not in this map degrade gracefully via the raw-code
* fallback in `buildLanguageClause`.
*/
export const LANGUAGE_NAMES: Record<string, string> = {
ar: 'Arabic',
de: 'German',
el: 'Greek',
en: 'English',
es: 'Spanish',
fi: 'Finnish',
fr: 'French',
he: 'Hebrew',
hi: 'Hindi',
id: 'Indonesian',
it: 'Italian',
ja: 'Japanese',
ko: 'Korean',
nl: 'Dutch',
no: 'Norwegian',
pl: 'Polish',
pt: 'Portuguese',
ru: 'Russian',
sv: 'Swedish',
th: 'Thai',
tr: 'Turkish',
uk: 'Ukrainian',
vi: 'Vietnamese',
zh: 'Chinese',
}
export {LANGUAGE_NAMES} from '../../../../shared/language/language-names.js'

import {LANGUAGE_NAMES as LANGUAGE_NAMES_LOCAL} from '../../../../shared/language/language-names.js'
Comment thread
RyanNg1403 marked this conversation as resolved.
Outdated

const AUTO_CLAUSE =
"Match the user's input language for human-readable content: body text of `<bv-*>` elements, list items, and the `title` / `summary` attributes on `<bv-topic>`. Keep tag names, attribute names, enum values, and the `path` attribute in English for tooling consistency. Code snippets and identifiers stay verbatim."
Expand Down Expand Up @@ -83,6 +54,6 @@ export function buildLanguageClause(language?: BrvConfigLanguage): string {
return AUTO_CLAUSE
}

const name = LANGUAGE_NAMES[language.code] ?? `"${language.code}"`
const name = LANGUAGE_NAMES_LOCAL[language.code] ?? `"${language.code}"`
return buildFixedClause(name)
}
2 changes: 1 addition & 1 deletion src/server/core/interfaces/storage/i-settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type SettingsStartupSnapshot = {
* Daemon startup logs this once; all values fall back to defaults.
*/
readonly parseError?: string
readonly values: Readonly<Record<string, boolean | number>>
readonly values: Readonly<Record<string, boolean | number | string>>
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/server/infra/daemon/settings-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type {ISettingsStore, SettingsStartupSnapshot} from '../../core/interfaces/storage/i-settings-store.js'

import {SETTINGS_KEYS} from '../../../shared/types/settings-keys.js'
import {
AGENT_MAX_CONCURRENT_TASKS,
AGENT_POOL_MAX_SIZE,
TASK_HISTORY_DEFAULT_MAX_ENTRIES,
} from '../../constants.js'
import {SETTINGS_KEYS} from '../../core/domain/entities/settings.js'

/**
* Daemon-side resolved view of every settings key the bootstrap path
Expand Down
Loading
Loading