Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

- A model of an existing kind is a drop-in: config only, no model names outside its adapter
([MODEL-DROPIN.md](docs/dev/MODEL-DROPIN.md)). `voice.speech_models` defaults to empty, so the `text_to_speech`
default speaks calls (it was Drama 3 then Kitten, named in code; Jarvis lists its chain). Voice cloning takes the
model's `clone_codebooks` and `clone_rate` (they were Fish Audio's 10 rows at 44.1 kHz; Jarvis sets 10). The model
management prompt names this box's models and what each needs, from the config, instead of a fixed roster. A
transcription model may declare `languages`: listed, a language outside them refused, and `/voice` offers only
those. The MCP `synthesize_speech` `instructions` description no longer names Drama 3; `tools/list` and
`/v1/models` are otherwise unchanged. `tests/dropin.zig` swaps a second model in for every kind. The companion's
language picker offers the box's transcription languages (Whisper's list offline). MCP `decide` with no `model` no
longer fails with "state is required" after the decision ran (a put on a shallow copy of the request).

- The image edit pack's `te/manifest.json` (and `edit.json`, which hashes it) is the same on every box: its vision config
is the checkpoint's architecture fields only, not transformers' `to_dict()`, which carried the installed version
(5.20.2 on the reporter's Spark, 5.21.0 on DGX-02: the only difference, with byte-identical weights), so a fresh
Expand Down
27 changes: 23 additions & 4 deletions apps/companion/src/app/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type { Migration, Secrets } from './secrets.ts'
import { grantStates, micAskAtStart, micResetBeforeAsk, signingId } from './grants.ts'
import type { Permission } from './grants.ts'
import { failure, json, mediaAllowed, ORIGIN, proxy } from './passthrough.ts'
import { DEFAULT_LANGUAGE, languageChoices, parseSettings, settingsText, sttLanguage } from './settings.ts'
import { DEFAULT_LANGUAGE, heardLanguages, languageChoices, parseSettings, settingsText, sttLanguage, type Heard } from './settings.ts'
import { createLogger } from '../../../desktop-helper/src/log.ts'
import { assistantNameProblem, assistantOf, DEFAULT_ASSISTANT, tidyAssistantName, voiceHint } from '../harness/assistant.ts'
import type { Assistant } from '../harness/assistant.ts'
Expand Down Expand Up @@ -288,6 +288,8 @@ const WINDOW_ICON = process.platform === 'linux' ? { icon: join(__dirname, 'icon
let runtime: Runtime | null = null
let legacy: LegacyBridge | null = null
let settings: Settings
/** The languages the Spark's transcription model hears (`/v1/models`); null: it lists none, or not read yet. */
let heard: Heard = null
let mainWindow: BrowserWindow | null = null
let setupWindow: BrowserWindow | null = null
let virtualCursor: VirtualCursor | null = null
Expand Down Expand Up @@ -613,7 +615,7 @@ function registerIpc() {
// The language a call hears: the page's picker reads and writes this computer's setting (Settings shows it too).
host('host:language', () => settings.language)
host('host:setLanguage', (v) => {
settings = { ...settings, language: sttLanguage(v) }
settings = { ...settings, language: sttLanguage(v, heard) }
saveSettings(settings)
return settings.language
})
Expand Down Expand Up @@ -804,7 +806,7 @@ function registerIpc() {
})
// "Ask before each action" (off by default): a card per consequential step instead of the standing grant.
setup('setup:language', (v) => {
settings = { ...settings, language: sttLanguage(v) }
settings = { ...settings, language: sttLanguage(v, heard) }
saveSettings(settings)
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('host:language', settings.language)
return state()
Expand Down Expand Up @@ -1010,13 +1012,30 @@ function useAssistant(a: Assistant | null) {
/** Reads the name on the Spark; a box that does not answer keeps the name last read. */
async function readAssistant(spark = runtime ? new Spark(settings.base, loadSecrets().token, timed(CHECK_S)) : null) {
if (!spark) return
void readLanguages(spark)
try {
useAssistant(await spark.assistant())
} catch (error) {
log.warn('assistant.read_failed', { error: error instanceof Error ? error.message : String(error) })
}
}

/** Reads them with the name; a language the model does not hear becomes English (or Auto-detect) for every call. */
async function readLanguages(spark: Spark) {
try {
heard = heardLanguages(await spark.models())
} catch (error) {
log.warn('languages.read_failed', { error: error instanceof Error ? error.message : String(error) })
return
}
const language = sttLanguage(settings.language, heard)
if (language === settings.language) return
settings = { ...settings, language }
saveSettings(settings)
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('host:language', settings.language)
log.info('languages.narrowed', { language, heard })
}

/** Why the Spark did not rename the assistant, for the person. */
function renameRefusal(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
Expand Down Expand Up @@ -1085,7 +1104,7 @@ async function state() {
control_granted: standing.granted(),
ask_each: settings.askEachAction === true,
language: settings.language,
languages: languageChoices(),
languages: languageChoices(heard),
granted_since: standing.since,
machine: machineKind(),
permissions: permissionState,
Expand Down
20 changes: 19 additions & 1 deletion apps/companion/src/app/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { languageChoices, LANGUAGES, parseSettings, settingsText, sttLanguage } from './settings.ts'
import { heardLanguages, languageChoices, LANGUAGES, parseSettings, settingsText, sttLanguage } from './settings.ts'

const dflt = { base: '', name: 'Mac', shared: [] as string[], legacy: false, language: 'en' }

Expand Down Expand Up @@ -64,6 +64,24 @@ describe('settings.json and the language a call hears', () => {
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, 'en')))
})

it('the box’s declared languages narrow the choices; without them (or offline) the built-in list', () => {
const models = [
{ id: 'other', kind: 'transcription', default_for: [], languages: ['fr'] },
{ id: 'ears', kind: 'transcription', default_for: ['speech_to_text'], languages: ['de', 'en', 'xx'] },
{ id: 'voice', kind: 'speech' },
]
const heard = heardLanguages(models)
expect(heard).toEqual(['de', 'en', 'xx'])
expect(languageChoices(heard)).toEqual([{ code: 'en', name: 'English' }, { code: 'auto', name: 'Auto-detect' }, { code: 'de', name: 'German' }, { code: 'xx', name: 'xx' }])
expect(sttLanguage('de', heard)).toBe('de')
expect(sttLanguage('ja', heard)).toBe('en')
expect(sttLanguage('ja', ['de'])).toBe('auto')
expect(languageChoices(['de'])[0]).toEqual({ code: 'auto', name: 'Auto-detect' })
expect(heardLanguages([{ id: 'ears', kind: 'transcription' }])).toBeNull()
expect(heardLanguages('nope')).toBeNull()
expect(languageChoices(null)).toEqual(languageChoices())
})

it('the app’s list is the voice page’s list', () => {
const page = readFileSync(join(import.meta.dirname, '../../../../src/voice/voice.html'), 'utf8')
const body = /const LANGUAGES = \{([^}]*)\};/.exec(page)?.[1] ?? ''
Expand Down
38 changes: 30 additions & 8 deletions apps/companion/src/app/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
* was chosen and never breaks on what was not.
*/

/** Whisper large-v3's languages, code to name: the same list as the `/voice` page's `LANGUAGES` (src/voice/voice.html). */
/**
* Language names, code to name: the same list as the `/voice` page's `LANGUAGES` (src/voice/voice.html). The choices
* are the box's transcription model's own `languages` (`heardLanguages`) when it lists them; these (Whisper large-v3's)
* otherwise, and while the box has not been read.
*/
export const LANGUAGES: Readonly<Record<string, string>> = {
af: 'Afrikaans', sq: 'Albanian', am: 'Amharic', ar: 'Arabic', hy: 'Armenian', as: 'Assamese', az: 'Azerbaijani',
ba: 'Bashkir', eu: 'Basque', be: 'Belarusian', bn: 'Bengali', bs: 'Bosnian', br: 'Breton', bg: 'Bulgarian',
Expand All @@ -25,15 +29,33 @@ export const LANGUAGES: Readonly<Record<string, string>> = {

export const DEFAULT_LANGUAGE = 'en'

/** A Whisper code or "auto" (detect it on each turn); anything else is English. */
export function sttLanguage(v: unknown): string {
return v === 'auto' || (typeof v === 'string' && Object.hasOwn(LANGUAGES, v)) ? v : DEFAULT_LANGUAGE
/** The languages the box hears: null for none listed (any of `LANGUAGES`). */
export type Heard = readonly string[] | null

/**
* The transcription model's declared `languages` from `/v1/models`' `data` (the one whose default_for lists
* speech_to_text, else the first transcription model), or null when it lists none.
*/
export function heardLanguages(models: unknown): Heard {
if (!Array.isArray(models)) return null
const stt = models.filter((m): m is Record<string, unknown> => !!m && typeof m === 'object' && (m as { kind?: unknown }).kind === 'transcription')
const m = stt.find((x) => Array.isArray(x.default_for) && x.default_for.includes('speech_to_text')) ?? stt[0]
const list = m?.languages
return Array.isArray(list) ? list.filter((c): c is string => typeof c === 'string' && c.length > 0) : null
}

/** A code the box hears, or "auto" (detect it on each turn); anything else is English, or "auto" if it hears none. */
export function sttLanguage(v: unknown, heard: Heard = null): string {
if (v === 'auto' || (typeof v === 'string' && (heard ? heard.includes(v) : Object.hasOwn(LANGUAGES, v)))) return v as string
return !heard || heard.includes(DEFAULT_LANGUAGE) ? DEFAULT_LANGUAGE : 'auto'
}

/** The picker's choices: English, Auto-detect, then the others by name. */
export function languageChoices(): Array<{ code: string; name: string }> {
const rest = Object.entries(LANGUAGES).filter(([c]) => c !== 'en').sort((a, b) => a[1].localeCompare(b[1], 'en'))
return [{ code: 'en', name: 'English' }, { code: 'auto', name: 'Auto-detect' }, ...rest.map(([code, name]) => ({ code, name }))]
/** The picker's choices: English (when heard), Auto-detect, then the others by name. */
export function languageChoices(heard: Heard = null): Array<{ code: string; name: string }> {
const all = heard ? heard.map((c): [string, string] => [c, LANGUAGES[c] ?? c]) : Object.entries(LANGUAGES)
const rest = all.filter(([c]) => c !== 'en').sort((a, b) => a[1].localeCompare(b[1], 'en'))
const en = !heard || heard.includes('en') ? [{ code: 'en', name: 'English' }] : []
return [...en, { code: 'auto', name: 'Auto-detect' }, ...rest.map(([code, name]) => ({ code, name }))]
}

/** settings.json's text (null: no file yet) over the defaults. */
Expand Down
2 changes: 1 addition & 1 deletion apps/companion/src/executor/decide.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Answers, Decider, Question } from '../../../desktop-helper/src/computer/decisions.ts'

/**
* The executor's decisions, asked of the chat model on the Spark as the box's `decide.Qwen` asks it (one completion,
* The executor's decisions, asked of the chat model on the Spark as the box's `decide.ChatDecider` asks it (one completion,
* a strict JSON schema whose enums are the offered keys, a stated confidence: high 0.9, medium 0.6, low 0.3). The
* answers are advice: each is checked against what was offered, and nothing here grants an action. `complete` sends an
* OpenAI Chat Completions body and returns the assistant's content (the harness's Spark client supplies it).
Expand Down
6 changes: 6 additions & 0 deletions apps/companion/src/harness/spark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ export class Spark {
return this.json('/v1/pair')
}

/** `/v1/models`' list (each model's id, kind, default_for and declared limits). */
async models(): Promise<unknown[]> {
const j = await this.json<{ data?: unknown }>('/v1/models')
return Array.isArray(j.data) ? j.data : []
}

/** The assistant's name on the box; null on a box from before the setting (404, 405, or 403 for the route). */
async assistant(): Promise<Assistant | null> {
const r = await this.request('/v1/assistant')
Expand Down
2 changes: 1 addition & 1 deletion deploy/jarvis/config.json.in
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"options": {"variant": "@DRAMA_VARIANT@", "voices": ["Mae", "Arlo", "Ezra", "Nora", "Theo", "Wren"], "default_voice": "Mae",
"voices_dir": "@DRAMA_ROOT@/voices", "min_speed": 1, "max_speed": 1,
"max_input_chars": 4096, "stream": true, "references": true, "instructions": "[{s}] ", "port": @DRAMA_PORT@,
"max_frames": 1024, "long_form": false, "verify": "size", "clone_working_bytes": 2147483648}},
"max_frames": 1024, "long_form": false, "verify": "size", "clone_working_bytes": 2147483648, "clone_codebooks": 10}},
{"id": "whisper-large-v3-turbo", "kind": "transcription", "name": "Whisper large-v3-turbo",
"cmd": ["@J@/venvs/whisper/bin/python", "@SRC@/tools/speech/whisper/worker.py"], "weights": "@WHISPER@",
"resident_bytes": 4026531840, "working_bytes": 536870912, "keep_loaded": true, "priority": 90, "idle_ttl_s": 3600,
Expand Down
3 changes: 2 additions & 1 deletion docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ multi-node LocalRouter each model also names the `node` that runs it; the API is
voice from recordings: [Voices](#voices-cloning)). Speech models also list `voices`, `default_voice`, `presets`, `speed` (the range they take),
`max_input_chars`, `stream` (whether `stream: true` works), `references` (whether voice references do) and
`instructions` (whether `instructions` do);
transcription models `max_audio_s`. `default_for` lists the capabilities the model serves
transcription models `max_audio_s`, and `languages` when the model hears only some (a `language` outside them is
refused). `default_for` lists the capabilities the model serves
when a request names no model (the operator's `defaults` in the config, else the first model that has the capability).
Name a model with `model` to choose one yourself, per request. A request that needs a capability the model lacks is
refused with a 400 naming the model and the capability; a model of the other kind is a 400 and an unknown one a 404,
Expand Down
9 changes: 6 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,10 @@ the model: input decoding, response encodings, and the limits checked before a t
- A cloned voice (`src/voices.zig`) is spoken with its own references: the daemon writes them to `voice.json` and
sends `"voice_references": "voice.json"` beside the voice's name; the tool speaks them after any `references` and
with nothing else (no voice of its own). A tool with `voice_clone` also encodes a voice: `"clone": "clone.json"`
(`{name, references: [{audio, text}]}`, prepared 44.1 kHz clips and their words, `input` empty) -> `voice.json`
(`{references: [{text, vq_codes, frames}]}`). The scheduler books such a job at the tool's `clone_working_bytes`
(`{name, references: [{audio, text}]}`, prepared clips and their words, `input` empty) -> `voice.json`
(`{references: [{text, vq_codes, frames}]}`). The clips are 44.1 kHz unless the tool's `clone_rate` says another
rate; `clone_codebooks` is the number of `vq_codes` rows the daemon expects (absent: any, the same for every
reference). The scheduler books such a job at the tool's `clone_working_bytes`
(the encoder's measured peak; 8 GiB when the config gives none) beside the loaded model.
- `speech.wav` is the only text to speech output. `/v1/audio/speech` returns it as `wav` or raw `pcm` (with
`x-sample-rate`, `x-channels` and `x-sample-format: s16le` headers), or encodes `flac`, `mp3`, `opus` or `aac`.
Expand All @@ -161,7 +163,8 @@ the model: input decoding, response encodings, and the limits checked before a t
model without a speed control), `max_input_chars` (at most 4096), `instructions` (a template with one `{s}`, such
as Drama 3's `"[{s}] "`: the daemon writes a request's `instructions` into `input` with it, after leading white
space and `<|...|>` tags, and checks `max_input_chars` on the result; absent: `instructions` are refused) and, for
transcription, `max_audio_s`. `/v1/models` and `list_models` show them.
transcription, `max_audio_s` and `languages` (the ISO-639-1 codes it hears; absent: any; a request's `language`
outside them is refused). `/v1/models` and `list_models` show them.
- A reply may say why the audio ended and where long-form segments start: `{"ok":true,"files":["speech.wav"],
"finish_reason":"length","segments":"2;220500"}`. `/v1/audio/speech` sends them as `x-finish-reason` and
`x-long-form-segments` (printable ASCII only; anything else is dropped).
Expand Down
2 changes: 1 addition & 1 deletion docs/HARNESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ With pairing off every route answers whoever reaches the daemon. Either way keep
`GET /v1/models` is OpenAI's list with LocalRouter's fields: `kind` (`chat`, `image`, `video`, `speech`,
`transcription`, `decision`), `capabilities`, `default_for`, `running`, `resident_bytes`, `qualified` (false: refused
with a 503 until the operator qualifies it on this GPU), and, for speech models, `voices`, `default_voice`, `presets`,
`speed`, `max_input_chars`, `stream`, `references`, `instructions`; for transcription, `max_audio_s`. `license`, when the config or
`speed`, `max_input_chars`, `stream`, `references`, `instructions`; for transcription, `max_audio_s` and `languages`. `license`, when the config or
the engine states one, is the weights' licence (`name`, `url`, `use`, `commercial`, `excluded_regions`, `notice`);
LocalRouter informs and does not enforce it ([AGENTS.md](AGENTS.md#licences)). Model ids are stable for a
deployment; a request without `model` goes to the model whose `default_for` lists the capability it needs.
Expand Down
2 changes: 1 addition & 1 deletion docs/dev/COMPUTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ It sends its API key field as the bearer token, so a device token allowed to app
## Replacing the decision provider

`src/computer/decide.zig` defines `Provider.ask(state, questions) -> answers`, the System One shape: Choice questions
over closed candidate sets and Noul (yes/no) questions, each answer with a probability. `decide.Qwen` implements it with
over closed candidate sets and Noul (yes/no) questions, each answer with a probability. `decide.ChatDecider` implements it with
the chat model. A Jev provider implements the same function against the open decision service (its `/v1/systemone`)
and is set as `hub.provider` in `api/desktop.zig`; `Provider.ask` re-validates every answer against the offered keys
whichever provider answers, and the policy, approvals and budgets do not change.
Expand Down
Loading
Loading