Check Point
Rolling release / release (push) Successful in 6m36s

This commit is contained in:
2026-09-12 09:07:09 -04:00
parent e9040d110a
commit a4073b9020
63 changed files with 2459 additions and 632 deletions
+8 -13
View File
@@ -87,17 +87,14 @@ root access and does not install into system directories.
curl -fsSL https://git.ssh.surf/snxraven/gnome-jarvis/raw/branch/main/packaging/web-installer.sh | bash -s -- --enable
~~~
Omit `--enable` to install without starting the service. Run the first-run
setup afterward:
~~~bash
bash ~/.local/share/jarvis-qvac/packaging/first-run.sh
gnome-extensions enable jarvis@qvac.local
Omit `--enable` to install files without starting the service. With
`--enable`, the installer writes default settings if needed, starts
`jarvisd`, and enables the GNOME extension. The default wake phrase is
**hey jarvis**; change it in Settings. Spoken replies are on.
If GNOME says the extension does not exist immediately after installation, log
out and back in once. GNOME Shell only refreshes its user extension catalogue
at session startup; the installer persists the enabled state for that login.
~~~
The rolling installer first downloads a self-contained architecture bundle
with the Bare ELF runtime and all application dependencies. It therefore needs
@@ -124,13 +121,12 @@ Install the user service, extension, and per-user QVAC configuration:
~~~bash
bash packaging/install.sh --enable
bash packaging/first-run.sh
gnome-extensions enable jarvis@qvac.local
~~~
The first-run flow checks GPU/QVAC and PipeWire, records the wake phrase and
model profile, optionally previews TTS, and sends a typed smoke-test prompt
through the session bus.
The installer writes `~/.config/jarvis/config.json` when it is missing, with
wake phrase `hey jarvis`, model profile `laptop-16gb`, and TTS enabled. Change
those later in Settings. Diagnostics stay available as `npm run gpu-doctor`,
`npm run voice-doctor`, and `npm run cu-doctor`.
Inspect the installed daemon with:
@@ -155,7 +151,6 @@ npm run voice-doctor # PipeWire, wake bridge, and playback diagnostics
npm run qvac:doctor # QVAC installation diagnostics
npm run qvac:status # Single-master runtime status
npm run package:test # Build and inspect release artifacts
npm run first-run # Interactive first-run setup
npm run start # Start jarvisd in the current session
~~~
+12 -8
View File
@@ -1,9 +1,13 @@
# Jarvis Control Center
# Jarvis settings
This package is the GTK4/libadwaita control-plane boundary. Its pages are
General, Voice, Models, Memory, Skills, Computer use, Privacy, Lab, and About.
The daemon remains the owner of QVAC, audio, portals, and the harness. The
implemented `main.py` application uses libadwaita NavigationSplitView, stores
preferences under `~/.config/jarvis/config.json`, and communicates with the
daemon over session D-Bus. Start it with `npm run --workspace
@jarvis-qvac/control-center start`.
Run `python3 apps/control-center/main.py` (or `gjs -m apps/control-center/main.js`).
The Control Center and the GNOME extension's Settings button now open the same
Libadwaita settings pages and use the same configuration catalog. Requires GJS,
GTK 4, and Libadwaita 1.4 or newer.
Voice, listening, and desktop settings are saved to
`$XDG_CONFIG_HOME/jarvis/config.json` (default `~/.config/jarvis/config.json`).
Use **Apply** or **Apply & Preview**. Appearance and shortcuts use GSettings and
apply immediately. Chat model and agent limits require restarting `jarvisd`.
See [settings reference](../../docs/settings.md) for details.
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/gjs -m
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import { fillSettingsWindow } from '../gnome-extension/[email protected]/settings-window.js';
const directory = Gio.File.new_for_uri(import.meta.url).get_parent().get_parent().get_child('gnome-extension').get_child('[email protected]').get_path();
const application = new Adw.Application({ application_id: 'io.qvac.Jarvis.Control', flags: Gio.ApplicationFlags.DEFAULT_FLAGS });
let window;
application.connect('activate', () => {
if (!window) {
const source = Gio.SettingsSchemaSource.new_from_directory(`${directory}/schemas`, Gio.SettingsSchemaSource.get_default(), false);
const settings = new Gio.Settings({ settings_schema: source.lookup('org.gnome.shell.extensions.jarvis', true) });
window = new Adw.PreferencesWindow({ application });
fillSettingsWindow(window, settings, directory);
window.connect('close-request', () => { window = null; return false; });
}
window.present();
});
application.run([]);
+5 -108
View File
@@ -1,115 +1,12 @@
#!/usr/bin/env python3
import json
"""Compatibility entry point for the shared Jarvis preferences window."""
import os
from pathlib import Path
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Adw, Gio, GLib, Gtk
CONFIG = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'jarvis' / 'config.json'
PAGES = [('general', 'General', 'Wake phrase, language, startup'), ('voice', 'Voice', 'Wake model, voice enrollment, preview'), ('models', 'Models', 'GPU runtime, fit, downloads'), ('memory', 'Memory', 'RAG workspaces and retention'), ('skills', 'Skills', 'Harness tools and confirmations'), ('computer', 'Computer use', 'Portal permissions and budgets'), ('privacy', 'Privacy', 'Storage, traces, deletion'), ('lab', 'Lab', 'BCI, VLA, ABot-World'), ('about', 'About', 'Versions and diagnostics')]
class Store:
def __init__(self):
try: self.data = json.loads(CONFIG.read_text())
except Exception: self.data = {}
def set(self, key, value):
self.data[key] = value; CONFIG.parent.mkdir(parents=True, exist_ok=True); CONFIG.write_text(json.dumps(self.data, indent=2) + '\n')
def main():
os.execvp('gjs', ['gjs', '-m', str(Path(__file__).with_name('main.js'))])
class ControlCenter(Adw.Application):
def __init__(self): super().__init__(application_id='io.qvac.Jarvis.Control', flags=Gio.ApplicationFlags.DEFAULT_FLAGS); self.store = Store(); self.rows = {}; self.proxy = None
def do_activate(self):
if getattr(self, 'window', None): self.window.present(); return
self.window = Adw.ApplicationWindow(application=self, title='Jarvis Control Center', default_width=1080, default_height=700)
self.window.set_content(self.build_shell()); self.connect_daemon(); self.window.present()
def build_shell(self):
split = Adw.NavigationSplitView(); sidebar = Adw.NavigationPage(title='Jarvis', child=self.build_sidebar(split)); content = Adw.NavigationPage(title='General', child=self.build_page('general')); split.set_sidebar(sidebar); split.set_content(content); self.content = content; self.split = split
shell = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
header = Adw.HeaderBar()
header.set_title_widget(Adw.WindowTitle(title='Jarvis', subtitle='Local voice assistant'))
shell.append(header); split.set_vexpand(True); shell.append(split); return shell
def build_sidebar(self, split):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12); box.set_margin_top(18); box.set_margin_bottom(18); box.set_margin_start(12); box.set_margin_end(12)
heading = Gtk.Label(label='JARVIS', xalign=0); heading.add_css_class('title-2'); box.append(heading)
local = Gtk.Label(label='LOCAL · QVAC MASTER', xalign=0); local.add_css_class('dim-label'); box.append(local)
listbox = Gtk.ListBox(selection_mode=Gtk.SelectionMode.SINGLE); listbox.add_css_class('navigation-sidebar');
for key, label, _subtitle in PAGES:
row = Gtk.ListBoxRow(); row.set_child(Gtk.Label(label=label, xalign=0, margin_top=10, margin_bottom=10, margin_start=12, margin_end=12)); row.set_name(key); listbox.append(row)
listbox.connect('row-selected', lambda _list, row: self.select_page(split, row.get_name() if row else 'general')); listbox.select_row(listbox.get_row_at_index(0)); box.append(listbox)
self.runtime = Gtk.Label(label='Connecting to jarvisd…', xalign=0, wrap=True); self.runtime.add_css_class('dim-label'); box.append(self.runtime); return box
def select_page(self, split, key):
if not hasattr(self, 'content'): return
self.content.set_child(self.build_page(key)); self.content.set_title(dict((p[0], p[1]) for p in PAGES)[key])
def build_page(self, key):
page = Gtk.ScrolledWindow(); page.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18); root.set_margin_top(28); root.set_margin_bottom(28); root.set_margin_start(32); root.set_margin_end(32); page.set_child(root)
title = next(p for p in PAGES if p[0] == key); header = Gtk.Label(label=title[1], xalign=0); header.add_css_class('title-1'); root.append(header); sub = Gtk.Label(label=title[2], xalign=0); sub.add_css_class('dim-label'); root.append(sub)
builders = {'general': self.general, 'voice': self.voice, 'models': self.models, 'memory': self.memory, 'skills': self.skills, 'computer': self.computer, 'privacy': self.privacy, 'lab': self.lab, 'about': self.about}; builders[key](root); return page
def group(self, root, title, subtitle=''):
group = Adw.PreferencesGroup(title=title, description=subtitle); root.append(group); return group
def entry(self, group, key, title, value=''):
row = Adw.EntryRow(title=title, text=str(self.store.data.get(key, value))); row.connect('changed', lambda item: self.store.set(key, item.get_text())); group.add(row); return row
def switch(self, group, key, title, active=True):
row = Adw.SwitchRow(title=title, active=bool(self.store.data.get(key, active))); row.connect('notify::active', lambda item, _pspec: self.store.set(key, item.get_active())); group.add(row); return row
def button(self, group, title, label, callback):
row = Adw.ActionRow(title=title); button = Gtk.Button(label=label, valign=Gtk.Align.CENTER); button.connect('clicked', callback); row.add_suffix(button); group.add(row); return row
def general(self, root):
group = self.group(root, 'General'); self.entry(group, 'wake_phrase', 'Wake phrase', 'hey jarvis'); self.entry(group, 'language', 'Language', 'en-US'); self.entry(group, 'hotkey', 'Hotkey', '<Super><Space>'); self.switch(group, 'startup', 'Start Jarvis when I log in', True)
def voice(self, root):
group = self.group(root, 'Speech and microphone', 'Hold Talk in the overlay to speak. Wake detection is optional. Changes apply after restarting Jarvis.')
self.voice_status = Gtk.Label(label='Checking voice readiness…', xalign=0, wrap=True)
root.append(self.voice_status)
self.entry(group, 'wake_command', 'Local wake bridge command')
self.switch(group, 'tts_enabled', 'Enable spoken replies', True)
self.button(group, 'Test your speakers', 'Speak preview', lambda _b: self.call('Say', '(s)', ['Jarvis voice preview.']))
self.button(group, 'Voice readiness', 'Refresh', lambda _b: self.call('GetRuntimeStatus'))
self.call('GetRuntimeStatus')
def models(self, root):
group = self.group(root, 'Single QVAC master'); self.model_status = Gtk.Label(label='Loading master status…', xalign=0, wrap=True); group.add(Adw.ActionRow(title='Runtime status', child=self.model_status)); self.entry(group, 'model', 'Active model', 'qwen3.5-4b'); self.button(group, 'Assess model fit', 'Assess', lambda _b: self.assess()); self.button(group, 'Model lifecycle', 'Download / resume', lambda _b: self.download()); self.button(group, 'Model lifecycle', 'Pause / cancel', lambda _b: self.call('CancelModel', '(s)', [self.store.data.get('model', 'qwen3.5-4b')]))
capabilities = self.group(root, 'Capabilities', 'Every capability remains visible when its model does not fit the GPU profile.')
for label in ['Chat · Plan · Summarize · Rewrite · Code', 'Embeddings · RAG · Batch prompts', 'Vision · OCR · Classification', 'Image · Video · Music generation', 'ASR · TTS · Translation · Voice clone', 'LoRA · BCI · VLA · ABot-World']:
capabilities.add(Adw.ActionRow(title=label, subtitle='Status supplied by the single QVAC master'))
def memory(self, root):
group = self.group(root, 'RAG workspaces'); self.entry(group, 'rag_workspace', 'Workspace name', 'home'); self.entry(group, 'rag_path', 'Folder to ingest'); self.button(group, 'Ingest local folder', 'Ingest', lambda _b: self.call('IngestPath', '(s)', [self.store.data.get('rag_path', '')])); self.button(group, 'Memory controls', 'Refresh workspaces', lambda _b: self.call('GetRuntimeStatus'))
def skills(self, root):
group = self.group(root, 'Harness permissions'); self.switch(group, 'confirm_destructive', 'Confirm destructive actions', True); self.switch(group, 'skills_computer', 'Enable computer-use tools', False); self.switch(group, 'skills_media', 'Enable media tools', True)
def computer(self, root):
group = self.group(root, 'Computer use'); self.switch(group, 'computer_observe', 'Observe-only mode', False); self.switch(group, 'computer_full', 'Full computer-use mode', False); self.entry(group, 'computer_step_budget', 'Step budget', '20'); self.button(group, 'Portal grant', 'Grant computer access', lambda _b: self.call('ComputerGrant', '(b)', [True])); self.button(group, 'Portal grant', 'Revoke immediately', lambda _b: self.call('ComputerRevoke'))
def privacy(self, root):
group = self.group(root, 'Privacy'); self.switch(group, 'save_transcripts', 'Save transcripts locally', False); self.switch(group, 'save_cu_traces', 'Save computer-use traces', False); self.switch(group, 'mute_schedule', 'Honor mute schedule', True); self.button(group, 'Computer traces', 'Delete temporary frames', lambda _b: self.call('WipeComputerTraces'))
def lab(self, root):
group = self.group(root, 'Research capabilities'); self.switch(group, 'lab_bci', 'BCI transcription slot', False); self.switch(group, 'lab_vla', 'VLA desktop actuator slot', False); self.switch(group, 'lab_world', 'ABot-World sandbox', False); self.entry(group, 'p2p_relays', 'Optional P2P relays')
def about(self, root):
group = self.group(root, 'About'); group.add(Adw.ActionRow(title='QVAC', subtitle='0.19.x · single local master')); group.add(Adw.ActionRow(title='Harness', subtitle=str(Path.home() / 'dev/agent-harness'))); group.add(Adw.ActionRow(title='Computer-use', subtitle='Wayland portal · AT-SPI · libei')); self.button(group, 'Diagnostics', 'Refresh status', lambda _b: self.call('GetRuntimeStatus'))
def connect_daemon(self):
try: self.proxy = Gio.DBusProxy.new_for_bus_sync(Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, 'io.qvac.Jarvis', '/io/qvac/Jarvis', 'io.qvac.Jarvis.Session'); self.call('GetRuntimeStatus')
except Exception as exc: self.runtime.set_text(f'jarvisd unavailable: {exc}')
def call(self, method, signature=None, args=None):
if not self.proxy: return
try:
self.runtime.set_text(f'{method}')
self.proxy.call(method, GLib.Variant(signature, args) if signature else None, Gio.DBusCallFlags.NONE, 5000, None, self._call_done, method)
except Exception as exc: self.runtime.set_text(f'{method}: {exc}')
def _call_done(self, proxy, result, method):
try: self.update_status(proxy.call_finish(result))
except Exception as exc: self.runtime.set_text(f'{method}: {exc}')
def update_status(self, result):
values = result.unpack() if result else (); value = values[0] if values else ''; self.runtime.set_text('Connected · local QVAC master');
try:
status = json.loads(value) if isinstance(value, str) else {}
voice = status.get('voice') or {}
if hasattr(self, 'voice_status') and 'voice' in status:
self.voice_status.set_text('\n'.join([
'Speech output: ' + ('ready' if voice.get('tts') else 'unavailable or disabled'),
'Microphone: ' + ('ready' if voice.get('asr') and voice.get('capture') else 'unavailable'),
'Wake phrase: ' + ('enabled' if voice.get('wake') else 'off — use Hold Talk'),
*[f'{key}: {message}' for key, message in voice.get('errors', {}).items()],
]))
except (ValueError, AttributeError): pass
if hasattr(self, 'model_status'): self.model_status.set_text(value)
def assess(self): self.call('AssessModelFit', '(s)', [self.store.data.get('model', 'qwen3.5-4b')])
def download(self): self.call('DownloadModel', '(s)', [self.store.data.get('model', 'qwen3.5-4b')])
def main(): return ControlCenter().run(None)
if __name__ == '__main__': main()
if __name__ == '__main__':
main()
@@ -18,14 +18,23 @@ const GLYPHS = { ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎
class JarvisProxy {
async connect() {
const proxy = await new Promise((resolve, reject) => Gio.DBusProxy.new(
Gio.DBus.session, Gio.DBusProxyFlags.DO_NOT_AUTO_START, null, BUS, PATH, IFACE, null,
if (this._closed) return this;
if (this.proxy) return this;
if (this._connecting) {
await this._connecting.catch(() => {});
return this;
}
this._connecting = new Promise((resolve, reject) => Gio.DBusProxy.new(
Gio.DBus.session, Gio.DBusProxyFlags.NONE, null, BUS, PATH, IFACE, null,
(source, result) => { try { resolve(Gio.DBusProxy.new_finish(result)); } catch (error) { reject(error); } },
));
try {
const proxy = await this._connecting;
if (this._closed) { proxy.run_dispose(); return this; }
this.proxy = proxy;
this._ownerSignal = proxy.connect('notify::g-name-owner', () => this.onOwnerChanged?.(this.owned()));
return this;
} finally { this._connecting = null; }
}
owned() { try { return Boolean(this.proxy?.g_name_owner || this.proxy?.get_name_owner?.()); } catch { return false; } }
call(name, signature = null, value = null) {
@@ -47,7 +56,7 @@ export default class JarvisExtension extends Extension {
this.settings = this.getSettings();
this.proxy = new JarvisProxy();
this.popup = new ConversationView({ compact: true });
this.osd = new JarvisOsd(); this.osd.attach();
this.osd = new JarvisOsd();
this.cu = new ComputerUseChrome(); this.cu.attach();
this.session = new SessionPanel(); this.session.attach();
this._bindSurface(this.popup);
@@ -55,7 +64,11 @@ export default class JarvisExtension extends Extension {
this._applyAccessibility();
this._removeShellService = installShellService();
this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant';
this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle'; this._indicator.add_child(this._glyph);
this._panelBox = new St.BoxLayout({ style_class: 'jarvis-panel-box', y_align: Clutter.ActorAlign.CENTER });
this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle';
this._panelBox.add_child(this._glyph);
this.osd.attach(this._panelBox);
this._indicator.add_child(this._panelBox);
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
this._mountPopup();
this._indicator.connect('button-press-event', (_actor, event) => {
@@ -173,6 +186,7 @@ export default class JarvisExtension extends Extension {
const proxy = this.proxy;
try {
await proxy.connect(); if (this.proxy !== proxy) return;
if (!this._signals) {
this._signals = [
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
['ContextReset', () => { this._eachView((view) => { view.clear(); view.setNotice('New conversation'); }); }],
@@ -193,8 +207,20 @@ export default class JarvisExtension extends Extension {
['Error', (code, message) => { this._eachView((view) => { view.setNotice(message); view.finishThinking(); }); if (coerceText(code) === 'VOICE_UNAVAILABLE') this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }],
].map(([name, handler]) => proxy.on(name, handler));
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
}
await this._syncDaemon();
} catch (error) { if (this.proxy !== proxy) return; this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
if (this.proxy !== proxy) return;
this._keepSyncing();
}
_keepSyncing() {
if (this._syncTimer) return;
this._syncTimer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 2000, () => {
if (!this.proxy || this.proxy._closed) { this._syncTimer = 0; return GLib.SOURCE_REMOVE; }
if (!this.proxy.proxy) this._connectDaemon();
else this._syncDaemon();
return GLib.SOURCE_CONTINUE;
});
}
async _syncDaemon() {
if (!this.proxy) return;
@@ -203,6 +229,9 @@ export default class JarvisExtension extends Extension {
const result = await this.proxy.call('GetState'); if (!this.popup) return;
this._setState(result.deep_unpack()[0]);
await this._refreshVoiceStatus();
this._eachView((view) => {
if (/daemon is unavailable|Jarvis is starting/i.test(view.notice?.text || '')) view.setNotice('');
});
} catch (error) { this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
}
async _refreshVoiceStatus() {
@@ -220,8 +249,14 @@ export default class JarvisExtension extends Extension {
} catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }
}
_call(name, signature, value) {
if (!this.proxy?.owned?.()) {
if (name !== 'PushToTalk') this._eachView((view) => view.setNotice('Jarvis is starting…'));
this._connectDaemon();
return;
}
this.proxy.call(name, signature, value).catch((error) => {
log(`Jarvis ${name}: ${error.message}`);
if (name === 'PushToTalk') return;
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
this._eachView((view) => view.setNotice(fallback));
});
@@ -258,6 +293,7 @@ export default class JarvisExtension extends Extension {
if (this._settingsChanged) this.settings.disconnect(this._settingsChanged);
if (this._styleChanged) this.settings.disconnect(this._styleChanged);
if (this._menuState) try { this._indicator.menu.disconnect(this._menuState); } catch {}
if (this._syncTimer) { try { GLib.Source.remove(this._syncTimer); } catch {} this._syncTimer = 0; }
this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id));
this.proxy?.close();
this.osd?.destroy(); this.cu?.destroy(); this.session?.destroy(); this.popup?.destroy();
@@ -1,181 +1,6 @@
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Gtk from 'gi://Gtk';
const BIND = Gio.SettingsBindFlags.DEFAULT;
const BUS = 'io.qvac.Jarvis';
const PATH = '/io/qvac/Jarvis';
const IFACE = 'io.qvac.Jarvis.Session';
const PRIVACY_MODES = ['full-listen-after-wake', 'wake-only', 'off'];
const OVERLAY_STYLES = ['tray', 'expanded'];
const COMPUTER_MODES = ['off', 'observe', 'act'];
const MODEL_PROFILES = ['laptop-8gb', 'laptop-16gb', 'desktop-gpu'];
function persistDaemonConfig(settings) {
const dir = GLib.build_filenamev([GLib.get_user_config_dir(), 'jarvis']);
GLib.mkdir_with_parents(dir, 0o755);
const file = Gio.File.new_for_path(GLib.build_filenamev([dir, 'config.json']));
let config = {};
try {
const [, contents] = file.load_contents(null);
const text = typeof contents === 'string' ? contents : new TextDecoder().decode(contents);
config = JSON.parse(text);
} catch {}
config.wakePhrase = settings.get_string('wake-phrase');
config.aliases = settings.get_strv('aliases');
config.ttsEnabled = settings.get_boolean('tts-enabled');
config.modelProfile = settings.get_string('model-profile');
file.replace_contents(`${JSON.stringify(config, null, 2)}\n`, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
}
function entryRow(settings, title, key) {
const row = new Adw.EntryRow({ title });
row.set_text(settings.get_string(key));
row.connect('changed', () => settings.set_string(key, row.get_text()));
return row;
}
function strvRow(settings, title, key) {
const row = new Adw.EntryRow({ title });
row.set_text(settings.get_strv(key).join(', '));
row.connect('changed', () => {
const values = row.get_text().split(',').map((item) => item.trim()).filter(Boolean);
settings.set_strv(key, values);
});
return row;
}
function switchRow(settings, title, subtitle, key) {
const row = new Adw.SwitchRow({ title, subtitle });
settings.bind(key, row, 'active', BIND);
return row;
}
function comboRow(settings, title, subtitle, key, values) {
const row = new Adw.ComboRow({ title, subtitle, model: Gtk.StringList.new(values) });
const current = settings.get_string(key);
row.selected = Math.max(0, values.indexOf(current));
row.connect('notify::selected', () => {
const value = values[row.selected];
if (value) settings.set_string(key, value);
});
return row;
}
function callDaemon(name, signature, values, onDone) {
Gio.DBus.session.call(
BUS,
PATH,
IFACE,
name,
signature ? GLib.Variant.new(signature, values) : null,
null,
Gio.DBusCallFlags.NONE,
4000,
null,
(_source, result) => {
try {
const reply = Gio.DBus.session.call_finish(result);
onDone?.(null, reply);
} catch (error) {
onDone?.(error);
}
},
);
}
function grantRow() {
const row = new Adw.ActionRow({
title: 'Desktop grant',
subtitle: 'Off. Jarvis cannot click or type until you allow it here.',
});
const allow = new Gtk.Button({ label: 'Allow now', valign: Gtk.Align.CENTER });
allow.add_css_class('suggested-action');
const revoke = new Gtk.Button({ label: 'Revoke', valign: Gtk.Align.CENTER });
revoke.add_css_class('destructive-action');
const refresh = () => {
callDaemon('ComputerStatus', null, null, (error, reply) => {
if (error) {
row.subtitle = 'Jarvis daemon is unavailable. Start jarvisd, then try Allow now.';
return;
}
let status = {};
try {
const unpacked = reply.deep_unpack?.() ?? reply.unpack?.();
const raw = Array.isArray(unpacked) ? unpacked[0] : unpacked;
status = JSON.parse(String(raw || '{}'));
} catch {}
if (status.active) {
row.subtitle = `Active. ${Number(status.steps_used) || 0} of ${Number(status.steps_max) || 20} steps used.`;
} else {
row.subtitle = 'Off. Press Allow now so Jarvis can observe or control the desktop for three minutes.';
}
});
};
allow.connect('clicked', () => {
callDaemon('ComputerGrant', '(b)', [true], (error) => {
row.subtitle = error ? `Could not grant: ${error.message}` : 'Grant requested. A portal prompt may appear.';
refresh();
});
});
revoke.connect('clicked', () => {
callDaemon('ComputerRevoke', null, null, (error) => {
row.subtitle = error ? `Could not revoke: ${error.message}` : 'Grant revoked.';
refresh();
});
});
row.add_suffix(allow);
row.add_suffix(revoke);
row.activatable_widget = allow;
refresh();
return row;
}
import { fillSettingsWindow } from './settings-window.js';
export default class JarvisPreferences extends ExtensionPreferences {
fillPreferencesWindow(window) {
window.set_title('Jarvis QVAC');
const settings = this.getSettings();
const voice = new Adw.PreferencesPage({ title: 'Voice', name: 'voice' });
const voiceGroup = new Adw.PreferencesGroup({ title: 'Speech and wake' });
voiceGroup.add(switchRow(settings, 'Spoken replies', 'Play Jarvis replies through local TTS', 'tts-enabled'));
voiceGroup.add(switchRow(settings, 'Wake chime', 'Play a short chime when Jarvis starts listening', 'chime-enabled'));
voiceGroup.add(entryRow(settings, 'Wake phrase', 'wake-phrase'));
voiceGroup.add(strvRow(settings, 'Wake aliases', 'aliases'));
voiceGroup.add(entryRow(settings, 'Voice id', 'voice-id'));
voiceGroup.add(entryRow(settings, 'Language', 'language'));
voice.add(voiceGroup);
const desktop = new Adw.PreferencesPage({ title: 'Desktop', name: 'desktop' });
const desktopGroup = new Adw.PreferencesGroup({ title: 'Overlay and shortcuts' });
desktopGroup.add(strvRow(settings, 'Hotkey', 'hotkey'));
desktopGroup.add(entryRow(settings, 'Accent color', 'accent-color'));
desktopGroup.add(comboRow(settings, 'Desktop layout', 'Tray keeps Jarvis in the top bar; expanded opens a conversation panel', 'overlay-style', OVERLAY_STYLES));
desktopGroup.add(switchRow(settings, 'Confirm destructive actions', 'Ask before write, delete, or computer-use changes', 'confirm-destructive'));
desktop.add(desktopGroup);
const computer = new Adw.PreferencesPage({ title: 'Computer use', name: 'computer' });
const computerGroup = new Adw.PreferencesGroup({ title: 'Desktop control' });
computerGroup.add(comboRow(settings, 'Computer use mode', 'Observe is read-only. Act can click and type only after you press Allow now.', 'computer-use-mode', COMPUTER_MODES));
computerGroup.add(grantRow());
computerGroup.add(switchRow(settings, 'Legacy input', 'Use the older input backend when portals are unavailable', 'computer-use-legacy-input'));
computerGroup.add(comboRow(settings, 'Privacy mode', 'How long Jarvis keeps the microphone open after wake', 'privacy-mode', PRIVACY_MODES));
computer.add(computerGroup);
const models = new Adw.PreferencesPage({ title: 'Models', name: 'models' });
const modelGroup = new Adw.PreferencesGroup({ title: 'Local profile' });
modelGroup.add(comboRow(settings, 'Model profile', 'Restart jarvisd after changing the QVAC profile', 'model-profile', MODEL_PROFILES));
models.add(modelGroup);
window.add(voice);
window.add(desktop);
window.add(computer);
window.add(models);
persistDaemonConfig(settings);
for (const key of ['wake-phrase', 'aliases', 'tts-enabled', 'model-profile']) {
settings.connect(`changed::${key}`, () => persistDaemonConfig(settings));
}
}
fillPreferencesWindow(window) { fillSettingsWindow(window, this.getSettings(), this.path); }
}
@@ -0,0 +1,745 @@
{
"version": 1,
"fields": [
{
"key": "ttsEnabled",
"title": "Spoken replies",
"group": "Speech",
"default": true,
"type": "boolean",
"description": "Read replies aloud using local speech synthesis.",
"aliases": [
"tts_enabled"
]
},
{
"key": "ttsPreset",
"title": "Speech model",
"group": "Speech",
"default": "supertonic-en",
"type": "choice",
"description": "Models download on first use. Larger models need more memory.",
"options": [
{
"value": "supertonic-en",
"label": "Supertonic \u00b7 English \u00b7 lightweight"
},
{
"value": "supertonic3",
"label": "Supertonic 3 \u00b7 multilingual"
},
{
"value": "chatterbox",
"label": "Chatterbox Turbo \u00b7 English \u00b7 reference voice"
},
{
"value": "parler",
"label": "Parler Mini \u00b7 English \u00b7 describe a voice"
}
]
},
{
"key": "voiceId",
"title": "Voice",
"group": "Speech",
"default": "F1",
"type": "choice",
"description": "Try the same preview with different voices. Available voices depend on the model bundle.",
"options": [
{
"value": "F1",
"label": "F1"
},
{
"value": "F2",
"label": "F2"
},
{
"value": "F3",
"label": "F3"
},
{
"value": "F4",
"label": "F4"
},
{
"value": "F5",
"label": "F5"
},
{
"value": "M1",
"label": "M1"
},
{
"value": "M2",
"label": "M2"
},
{
"value": "M3",
"label": "M3"
},
{
"value": "M4",
"label": "M4"
},
{
"value": "M5",
"label": "M5"
}
],
"aliases": [
"voice_id",
"voice-id"
],
"when": {
"ttsPreset": [
"supertonic-en",
"supertonic3"
]
}
},
{
"key": "ttsLanguage",
"title": "Speech language",
"group": "Speech",
"default": "en",
"type": "choice",
"description": "The English models always use English. Choose Supertonic 3 for other languages.",
"options": [
{
"value": "en",
"label": "English"
},
{
"value": "ko",
"label": "Korean"
},
{
"value": "ja",
"label": "Japanese"
},
{
"value": "ar",
"label": "Arabic"
},
{
"value": "bg",
"label": "Bulgarian"
},
{
"value": "cs",
"label": "Czech"
},
{
"value": "da",
"label": "Danish"
},
{
"value": "de",
"label": "German"
},
{
"value": "el",
"label": "Greek"
},
{
"value": "es",
"label": "Spanish"
},
{
"value": "et",
"label": "Estonian"
},
{
"value": "fi",
"label": "Finnish"
},
{
"value": "fr",
"label": "French"
},
{
"value": "hi",
"label": "Hindi"
},
{
"value": "hr",
"label": "Croatian"
},
{
"value": "hu",
"label": "Hungarian"
},
{
"value": "id",
"label": "Indonesian"
},
{
"value": "it",
"label": "Italian"
},
{
"value": "lt",
"label": "Lithuanian"
},
{
"value": "lv",
"label": "Latvian"
},
{
"value": "nl",
"label": "Dutch"
},
{
"value": "pl",
"label": "Polish"
},
{
"value": "pt",
"label": "Portuguese"
},
{
"value": "ro",
"label": "Romanian"
},
{
"value": "ru",
"label": "Russian"
},
{
"value": "sk",
"label": "Slovak"
},
{
"value": "sl",
"label": "Slovenian"
},
{
"value": "sv",
"label": "Swedish"
},
{
"value": "tr",
"label": "Turkish"
},
{
"value": "uk",
"label": "Ukrainian"
},
{
"value": "vi",
"label": "Vietnamese"
}
],
"aliases": [
"tts_language"
],
"when": {
"ttsPreset": [
"supertonic3"
]
}
},
{
"key": "ttsSpeed",
"title": "Speaking speed",
"group": "Speech",
"default": 1.05,
"type": "number",
"description": "1 is normal speed; lower is slower.",
"min": 0.25,
"max": 4,
"step": 0.05,
"aliases": [
"tts_speed"
],
"when": {
"ttsPreset": [
"supertonic-en",
"supertonic3"
]
}
},
{
"key": "ttsSteps",
"title": "Voice quality steps",
"group": "Speech",
"default": 5,
"type": "number",
"description": "More steps can improve quality but take longer.",
"min": 1,
"max": 30,
"step": 1,
"when": {
"ttsPreset": [
"supertonic-en",
"supertonic3"
]
}
},
{
"key": "ttsVolume",
"title": "Reply volume (%)",
"group": "Speech",
"default": 100,
"type": "number",
"description": "Relative to the system speaker volume. Does not change other apps.",
"min": 0,
"max": 100,
"step": 5
},
{
"key": "ttsReferenceAudio",
"title": "Reference voice recording",
"group": "Voice design",
"default": "",
"type": "file",
"description": "Choose a clear mono WAV recording of at least 5 seconds. Leave empty for the model\u2019s default voice.",
"when": {
"ttsPreset": [
"chatterbox"
]
}
},
{
"key": "ttsCfmSteps",
"title": "Synthesis quality steps",
"group": "Voice design",
"default": 2,
"type": "number",
"description": "Chatterbox: fewer steps respond faster.",
"min": 1,
"max": 10,
"step": 1,
"when": {
"ttsPreset": [
"chatterbox"
]
}
},
{
"key": "ttsDescription",
"title": "Describe the voice",
"group": "Voice design",
"default": "A clear, warm voice speaks at a natural pace in a quiet room.",
"type": "string",
"description": "Parler: describe tone, pace, pitch, and recording style in English.",
"when": {
"ttsPreset": [
"parler"
]
}
},
{
"key": "ttsTemperature",
"title": "Voice variation",
"group": "Voice design",
"default": 1,
"type": "number",
"description": "Parler: higher values produce more variation.",
"min": 0,
"max": 2,
"step": 0.05,
"when": {
"ttsPreset": [
"parler"
]
}
},
{
"key": "ttsSeed",
"title": "Voice seed",
"group": "Voice design",
"default": 42,
"type": "number",
"description": "Use a fixed seed for repeatable speech generation.",
"min": 0,
"max": 2147483647,
"step": 1,
"when": {
"ttsPreset": [
"chatterbox",
"parler"
]
}
},
{
"key": "ttsThreads",
"title": "Speech CPU threads",
"group": "Voice design",
"default": 4,
"type": "number",
"description": "Limit CPU work for Chatterbox and Parler.",
"min": 1,
"max": 32,
"step": 1,
"when": {
"ttsPreset": [
"chatterbox",
"parler"
]
}
},
{
"key": "ttsUseGpu",
"title": "Accelerate speech with GPU",
"group": "Voice design",
"default": false,
"type": "boolean",
"description": "Use a supported GPU backend for speech synthesis. May increase GPU memory use."
},
{
"key": "ttsModel",
"title": "Custom speech model",
"group": "Voice design",
"default": "",
"type": "string",
"description": "Advanced: registry name or local GGUF matching the selected speech model. Empty uses the bundled preset.",
"aliases": [
"tts_model"
]
},
{
"key": "previewText",
"title": "Preview text",
"group": "Speech",
"default": "Hello. I am Jarvis. This is how I will sound with your settings.",
"type": "string",
"description": "Use the same sentence to compare voices."
},
{
"key": "microphoneEnabled",
"title": "Microphone input",
"group": "Listening",
"default": true,
"type": "boolean",
"description": "Turn off for typed chat and speech output only.",
"aliases": [
"microphone_enabled"
]
},
{
"key": "asrModel",
"title": "Recognition model",
"group": "Listening",
"default": "WHISPER_TINY",
"type": "choice",
"description": "Larger models need more memory and download on first use.",
"options": [
{
"value": "WHISPER_TINY",
"label": "Whisper Tiny \u00b7 fastest"
},
{
"value": "WHISPER_BASE_Q8_0",
"label": "Whisper Base \u00b7 balanced"
},
{
"value": "WHISPER_SMALL_Q8_0",
"label": "Whisper Small \u00b7 more accurate"
}
],
"aliases": [
"asr_model"
]
},
{
"key": "asrLanguage",
"title": "Recognition language",
"group": "Listening",
"default": "en",
"type": "string",
"description": "Whisper language code such as en, es, fr, or auto.",
"aliases": [
"asr_language",
"language"
]
},
{
"key": "wakePhrase",
"title": "Wake phrase",
"group": "Wake and privacy",
"default": "hey jarvis",
"type": "string",
"description": "Must match a phrase supported by your local wake detector.",
"aliases": [
"wake_phrase"
]
},
{
"key": "aliases",
"title": "Wake aliases",
"group": "Wake and privacy",
"default": [
"jarvis",
"okay jarvis"
],
"type": "list",
"description": "Additional detector phrases, separated by commas."
},
{
"key": "wakeCommand",
"title": "Wake detector command",
"group": "Wake and privacy",
"default": "",
"type": "string",
"description": "Advanced: local program that receives microphone audio. Leave empty to use Hold Talk.",
"aliases": [
"wake_command"
]
},
{
"key": "listeningMode",
"title": "Listening behavior",
"group": "Wake and privacy",
"default": "conversation",
"type": "choice",
"description": "Hold Talk only disables the wake detector and automatic follow-up listening.",
"options": [
{
"value": "conversation",
"label": "Continue listening after replies"
},
{
"value": "single",
"label": "One request per wake"
},
{
"value": "ptt",
"label": "Hold Talk only"
}
],
"aliases": [
"listening_mode"
]
},
{
"key": "idleMinutes",
"title": "Idle sleep delay (minutes)",
"group": "Wake and privacy",
"default": 30,
"type": "number",
"description": "Sleep after this much listening inactivity.",
"min": 1,
"max": 240,
"step": 1
},
{
"key": "vadThreshold",
"title": "Speech detection threshold",
"group": "Detection tuning",
"default": 0.6,
"type": "number",
"description": "Lower picks up quieter speech; higher rejects more background noise.",
"min": 0.05,
"max": 1,
"step": 0.05
},
{
"key": "vadMinSpeechMs",
"title": "Minimum speech (ms)",
"group": "Detection tuning",
"default": 300,
"type": "number",
"description": "Ignore very short sounds during automatic listening.",
"min": 100,
"max": 2000,
"step": 50
},
{
"key": "vadSilenceMs",
"title": "Pause before sending (ms)",
"group": "Detection tuning",
"default": 700,
"type": "number",
"description": "Wait this long after speech before sending your request.",
"min": 200,
"max": 3000,
"step": 50
},
{
"key": "vadMaxSpeechSeconds",
"title": "Maximum recording (seconds)",
"group": "Detection tuning",
"default": 15,
"type": "number",
"description": "Limit each recorded utterance, including pauses.",
"min": 3,
"max": 120,
"step": 1
},
{
"key": "playbackCooldownMs",
"title": "Echo protection after replies (ms)",
"group": "Detection tuning",
"default": 400,
"type": "number",
"description": "Delay microphone processing after speech output ends.",
"min": 0,
"max": 2000,
"step": 50
},
{
"key": "inputTarget",
"title": "Microphone device",
"group": "Audio routing",
"default": "",
"type": "device",
"description": "System default follows your desktop sound settings. Refresh the list after connecting a device."
},
{
"key": "outputTarget",
"title": "Speaker device",
"group": "Audio routing",
"default": "",
"type": "device",
"description": "System default follows your desktop sound settings. Refresh the list after connecting a device."
},
{
"key": "computerMode",
"title": "Desktop access mode",
"group": "Desktop access",
"default": "act",
"type": "choice",
"description": "A temporary Allow now grant is always required. Changing this revokes existing access.",
"options": [
{
"value": "off",
"label": "Disabled"
},
{
"value": "observe",
"label": "Observe only"
},
{
"value": "act",
"label": "Observe and control"
}
],
"aliases": [
"computer_mode"
]
},
{
"key": "computerSteps",
"title": "Actions per grant",
"group": "Desktop access",
"default": 20,
"type": "number",
"description": "Maximum input actions before another grant is needed.",
"min": 1,
"max": 100,
"step": 1,
"aliases": [
"computer_step_budget"
]
},
{
"key": "computerGrantMinutes",
"title": "Grant duration (minutes)",
"group": "Desktop access",
"default": 3,
"type": "number",
"description": "Desktop access expires automatically.",
"min": 1,
"max": 15,
"step": 1
},
{
"key": "screenshotMaxEdge",
"title": "Screenshot maximum edge (pixels)",
"group": "Desktop images",
"default": 1280,
"type": "number",
"description": "Larger screenshots preserve detail but take more memory.",
"min": 640,
"max": 2560,
"step": 160
},
{
"key": "screenshotQuality",
"title": "Screenshot quality (%)",
"group": "Desktop images",
"default": 70,
"type": "number",
"description": "Higher WebP quality preserves more text detail.",
"min": 30,
"max": 95,
"step": 5
},
{
"key": "modelProfile",
"title": "Chat model profile",
"group": "Chat model",
"default": "laptop-16gb",
"type": "choice",
"description": "Requires a daemon restart. GPU inference remains required.",
"options": [
{
"value": "laptop-8gb",
"label": "Small \u00b7 Qwen3 1.7B"
},
{
"value": "laptop-16gb",
"label": "Balanced \u00b7 Qwen3.5 4B"
},
{
"value": "desktop-gpu",
"label": "Large \u00b7 Qwen3.5 9B"
}
],
"aliases": [
"model_profile"
],
"restart": true
},
{
"key": "chatModel",
"title": "Custom chat model",
"group": "Chat model",
"default": "",
"type": "string",
"description": "Advanced: overrides the profile. Empty uses the profile model. Requires restart.",
"aliases": [
"model"
],
"restart": true
},
{
"key": "maxTurns",
"title": "Maximum reasoning turns",
"group": "Agent limits",
"default": 6,
"type": "number",
"description": "Requires restart. Limits how long the assistant works on one request.",
"min": 1,
"max": 30,
"step": 1,
"restart": true
},
{
"key": "maxShellCalls",
"title": "Shell commands per request",
"group": "Agent limits",
"default": 1,
"type": "number",
"description": "Requires restart. Commands still require normal permissions.",
"min": 1,
"max": 10,
"step": 1,
"restart": true
},
{
"key": "maxToolRounds",
"title": "Tool rounds per request",
"group": "Agent limits",
"default": 4,
"type": "number",
"description": "Requires restart. Limits repeated tool use.",
"min": 1,
"max": 20,
"step": 1,
"restart": true
}
]
}
@@ -0,0 +1,221 @@
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Gtk from 'gi://Gtk';
import { normalizeSettings, mergeSettings, settingVisible } from './settings-values.js';
function readJson(file, optional = false) {
try {
const [, bytes] = file.load_contents(null);
const value = JSON.parse(new TextDecoder().decode(bytes));
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected a JSON object');
return value;
} catch (error) {
if (optional && error.matches?.(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND)) return {};
throw error;
}
}
export function daemonCall(method, signature = null, values = null) {
return new Promise((resolve, reject) => Gio.DBus.session.call(
'io.qvac.Jarvis', '/io/qvac/Jarvis', 'io.qvac.Jarvis.Session', method,
signature ? new GLib.Variant(signature, values) : null, null,
Gio.DBusCallFlags.NONE, 180000, null,
(_source, result) => { try { resolve(Gio.DBus.session.call_finish(result).deep_unpack()); } catch (error) { reject(error); } },
));
}
async function audioDevices(mediaClass) {
const process = Gio.Subprocess.new(['pw-dump'], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
let timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 5000, () => { timeout = 0; process.force_exit(); return GLib.SOURCE_REMOVE; });
try {
const output = await new Promise((resolve, reject) => process.communicate_utf8_async(null, null, (child, result) => {
try { const [, stdout, stderr] = child.communicate_utf8_finish(result); if (!child.get_successful()) throw new Error(stderr || 'PipeWire unavailable'); resolve(stdout); } catch (error) { reject(error); }
}));
return JSON.parse(output).filter(item => item.info?.props?.['media.class'] === mediaClass).map(item => ({ value: item.info.props['node.name'], label: item.info.props['node.description'] || item.info.props['node.nick'] || item.info.props['node.name'] })).filter(item => item.value);
} finally { if (timeout) GLib.Source.remove(timeout); }
}
export class SettingsEditor {
constructor(directory, legacySettings = null) {
this.fields = readJson(Gio.File.new_for_path(`${directory}/settings-catalog.json`)).fields;
this.file = Gio.File.new_for_path(GLib.build_filenamev([GLib.get_user_config_dir(), 'jarvis', 'config.json']));
this.changes = {}; this.rows = new Map(); this.statusRows = []; this.applyButtons = []; this.previewButtons = []; this.busy = false;
try { this.source = readJson(this.file, true); } catch (error) { this.source = {}; this.loadError = error.message; }
// Existing JSON is authoritative. Migrate only explicitly changed GSettings,
// never its defaults, and never write simply because preferences opened.
const legacy = { 'wake-phrase': 'wakePhrase', aliases: 'aliases', 'voice-id': 'voiceId', language: 'asrLanguage', 'tts-enabled': 'ttsEnabled', 'model-profile': 'modelProfile', 'computer-use-mode': 'computerMode' };
if (!this.loadError && legacySettings) for (const [key, canonical] of Object.entries(legacy)) {
const field = this.fields.find(item => item.key === canonical);
if ([canonical, ...(field.aliases || [])].some(name => this.source[name] != null)) continue;
const value = legacySettings.get_user_value(key);
if (value) this.changes[canonical] = value.deep_unpack();
}
this.values = normalizeSettings({ ...this.source, ...this.changes }, this.fields);
}
status(message) { this.message = message; for (const row of this.statusRows) row.subtitle = message; }
setValue(field, value) {
if (this._syncing) return;
this.values[field.key] = value; this.changes[field.key] = value;
this.status('Unsaved changes. Apply to use these settings.');
this.refreshVisibility();
}
refreshVisibility() {
for (const field of this.fields) {
const row = this.rows.get(field.key);
if (row) row.visible = settingVisible(field, this.values);
}
for (const button of this.previewButtons) button.sensitive = !this.busy && this.values.ttsEnabled;
}
row(field, parent) {
let row;
const changed = value => this.setValue(field, value);
if (field.type === 'boolean') {
row = new Adw.SwitchRow({ title: field.title, subtitle: field.description, active: this.values[field.key] });
row.connect('notify::active', () => changed(row.active));
row._setValue = value => { row.active = value; };
} else if (field.type === 'choice') {
row = new Adw.ComboRow({ title: field.title, subtitle: field.description, model: Gtk.StringList.new(field.options.map(option => option.label)) });
row.selected = Math.max(0, field.options.findIndex(option => option.value === this.values[field.key]));
row.connect('notify::selected', () => { if (field.options[row.selected]) changed(field.options[row.selected].value); });
row._setValue = value => { row.selected = field.options.findIndex(option => option.value === value); };
} else if (field.type === 'device') {
let options = [{ value: '', label: 'System default' }];
if (this.values[field.key]) options.push({ value: this.values[field.key], label: this.values[field.key] });
row = new Adw.ComboRow({ title: field.title, subtitle: field.description, model: Gtk.StringList.new(options.map(o => o.label)), selected: options.length - 1 });
let updating = false;
row.connect('notify::selected', () => { if (!updating && options[row.selected]) changed(options[row.selected].value); });
const refresh = new Gtk.Button({ label: 'Refresh', valign: Gtk.Align.CENTER });
const scan = async () => {
refresh.sensitive = false;
try {
const devices = await audioDevices(field.key === 'inputTarget' ? 'Audio/Source' : 'Audio/Sink');
options = [{ value: '', label: 'System default' }, ...devices];
if (this.values[field.key] && !options.some(o => o.value === this.values[field.key])) options.push({ value: this.values[field.key], label: `${this.values[field.key]} (not connected)` });
updating = true;
row.model = Gtk.StringList.new(options.map(o => o.label));
row.selected = Math.max(0, options.findIndex(o => o.value === this.values[field.key]));
row.subtitle = field.description;
} catch (error) { row.subtitle = `Could not list devices: ${error.message}`; }
finally { updating = false; refresh.sensitive = true; }
};
refresh.connect('clicked', scan); row.add_suffix(refresh);
row._setValue = value => { row.selected = Math.max(0, options.findIndex(o => o.value === value)); };
scan();
} else if (field.type === 'number') {
row = new Adw.SpinRow({ title: field.title, subtitle: field.description, digits: field.step < 1 ? 2 : 0,
adjustment: new Gtk.Adjustment({ lower: field.min, upper: field.max, step_increment: field.step, page_increment: field.step * 5, value: this.values[field.key] }) });
row.connect('notify::value', () => changed(row.value));
row._setValue = value => { row.value = value; };
} else if (field.type === 'file') {
row = new Adw.ActionRow({ title: field.title, subtitle: this.values[field.key] || field.description });
const choose = new Gtk.Button({ label: 'Choose WAV…', valign: Gtk.Align.CENTER });
const clear = new Gtk.Button({ label: 'Clear', valign: Gtk.Align.CENTER });
choose.connect('clicked', () => {
const dialog = new Gtk.FileChooserNative({ title: 'Choose a reference voice recording', transient_for: parent, action: Gtk.FileChooserAction.OPEN, accept_label: 'Choose' });
const filter = new Gtk.FileFilter(); filter.set_name('WAV audio'); filter.add_pattern('*.wav'); filter.add_pattern('*.WAV'); dialog.add_filter(filter);
dialog.connect('response', (_dialog, response) => {
if (response === Gtk.ResponseType.ACCEPT) { const file = dialog.get_file()?.get_path(); if (file) { changed(file); row.subtitle = file; } }
dialog.destroy();
});
dialog.show();
});
clear.connect('clicked', () => { changed(''); row.subtitle = field.description; });
row.add_suffix(choose); row.add_suffix(clear);
row._setValue = value => { row.subtitle = value || field.description; };
} else {
row = new Adw.EntryRow({ title: field.title, tooltip_text: field.description, text: field.type === 'list' ? this.values[field.key].join(', ') : this.values[field.key] });
row.connect('changed', () => changed(field.type === 'list' ? row.text.split(',').map(v => v.trim()).filter(Boolean) : row.text));
row._setValue = value => { row.text = field.type === 'list' ? value.join(', ') : value; };
}
this.rows.set(field.key, row);
return row;
}
controls(page, fields, { preview = false } = {}) {
const group = new Adw.PreferencesGroup({ title: preview ? 'Make it sound like you want' : 'Apply your changes', description: 'Voice, listening, and desktop controls apply here. Chat model and agent limits require restarting Jarvis.' });
const row = new Adw.ActionRow({ title: preview ? 'Listen before you settle on a voice' : 'Settings', subtitle: this.loadError ? `Could not read config.json: ${this.loadError}` : 'Saved locally. Nothing changes until you press Apply.' });
this.statusRows.push(row);
const apply = new Gtk.Button({ label: 'Apply', valign: Gtk.Align.CENTER }); apply.add_css_class('suggested-action');
apply.connect('clicked', () => this.apply()); this.applyButtons.push(apply); row.add_suffix(apply);
if (preview) {
const listen = new Gtk.Button({ label: 'Apply & Preview', valign: Gtk.Align.CENTER });
listen.connect('clicked', () => this.apply(true)); this.previewButtons.push(listen); row.add_suffix(listen);
const stop = new Gtk.Button({ label: 'Stop', valign: Gtk.Align.CENTER });
stop.connect('clicked', () => daemonCall('StopSpeech').catch(error => this.status(error.message))); row.add_suffix(stop);
}
group.add(row);
const reset = new Adw.ActionRow({ title: 'Reset this page', subtitle: 'Restore defaults in the form, then Apply to save them.' });
const button = new Gtk.Button({ label: 'Reset', valign: Gtk.Align.CENTER });
button.connect('clicked', () => { for (const field of fields) { this.setValue(field, field.default); this.rows.get(field.key)?._setValue(field.default); } });
reset.add_suffix(button); group.add(reset); page.add(group);
}
page(parent, title, groups, iconName, preview = false) {
const page = new Adw.PreferencesPage({ title, icon_name: iconName });
const fields = this.fields.filter(field => groups.includes(field.group));
this.controls(page, fields, { preview });
for (const name of groups) {
const group = new Adw.PreferencesGroup({ title: name });
for (const field of fields.filter(item => item.group === name)) {
group.add(this.row(field, parent));
// EntryRow has no subtitle. Show advanced guidance underneath it.
if (['string', 'list'].includes(field.type)) {
const help = new Gtk.Label({ label: field.description, wrap: true, xalign: 0, margin_start: 12, margin_end: 12, margin_bottom: 8 });
help.add_css_class('dim-label');
this.rows.get(field.key).bind_property('visible', help, 'visible', 2);
group.add(help);
}
}
page.add(group);
}
this.refreshVisibility();
return page;
}
save() {
if (this.loadError) throw new Error(`Fix config.json before saving: ${this.loadError}`);
// Re-read on save so another window's unrelated changes survive.
const current = readJson(this.file, true);
const merged = mergeSettings(current, this.changes, this.fields);
const validated = normalizeSettings(merged, this.fields, { strict: true });
for (const key of Object.keys(this.changes)) merged[key] = validated[key];
GLib.mkdir_with_parents(this.file.get_parent().get_path(), 0o700);
this.file.replace_contents(`${JSON.stringify(merged, null, 2)}\n`, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
this.source = merged; this.changes = {}; this.values = validated;
this._syncing = true;
try { for (const field of this.fields) this.rows.get(field.key)?._setValue(validated[field.key]); }
finally { this._syncing = false; this.refreshVisibility(); }
return validated;
}
async restart() {
if (this.busy) return;
this.busy = true;
try {
this.save(); this.status('Saved. Restarting Jarvis…');
const process = Gio.Subprocess.new(['systemctl', '--user', 'restart', 'jarvisd.service'], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE);
await new Promise((resolve, reject) => process.communicate_utf8_async(null, null, (child, result) => {
try { const [, , stderr] = child.communicate_utf8_finish(result); if (!child.get_successful()) throw new Error(stderr || 'Restart failed'); resolve(); } catch (error) { reject(error); }
}));
this.status('Jarvis restarted. Voice models may take a moment to become ready.');
} catch (error) { this.status(`Saved settings may still need applying: ${error.message}`); }
finally { this.busy = false; this.refreshVisibility(); }
}
async apply(preview = false) {
if (this.busy) return;
this.busy = true; for (const button of this.applyButtons) button.sensitive = false; this.refreshVisibility();
try {
if (this.loadError) throw new Error(`Fix config.json before saving: ${this.loadError}`);
const validated = this.save();
this.status('Saved. Applying settings and preparing voice models…');
let reply;
try { [reply] = await daemonCall('ReloadSettings'); }
catch (error) { throw new Error(`Saved, but not applied: ${error.message}`); }
const status = JSON.parse(reply);
const errors = Object.entries(status.voice?.errors || {}).filter(([key]) => key !== 'tts' || validated.ttsEnabled).map(([key, message]) => `${key}: ${message}`);
const restart = status.restartRequired?.length ? ' Restart Jarvis for chat model / agent changes.' : '';
const overrides = status.environmentOverrides?.length ? ` Environment overrides: ${status.environmentOverrides.join(', ')}.` : '';
if (errors.length) { this.status(`Applied.${restart}${overrides} ${errors.join(' · ')}`); return; }
if (preview) { this.status('Playing your voice preview…'); await daemonCall('PreviewVoice', '(s)', [validated.previewText]); }
this.status(`Applied.${restart}${overrides}${preview ? ' Preview finished.' : ''}`);
} catch (error) { this.status(error.message); }
finally { this.busy = false; for (const button of this.applyButtons) button.sensitive = true; this.refreshVisibility(); }
}
}
@@ -0,0 +1,43 @@
// Shared by GNOME preferences and the daemon; no platform-specific imports.
export function normalizeSettings(source, fields, { strict = false } = {}) {
const config = source && typeof source === 'object' && !Array.isArray(source) ? source : {};
const result = {};
for (const field of fields) {
let value = config[field.key];
if (value == null) {
for (const alias of field.aliases || []) if (config[alias] != null) { value = config[alias]; break; }
}
if (value == null) value = field.default;
if (field.type === 'boolean' && typeof value === 'string') {
const flag = value.trim().toLowerCase();
if (['false', '0', 'off', 'no'].includes(flag)) value = false;
else if (['true', '1', 'on', 'yes'].includes(flag)) value = true;
}
if (field.type === 'number' && typeof value === 'string' && value.trim()) value = Number(value);
if (field.type === 'list' && typeof value === 'string') value = value.split(',').map(v => v.trim()).filter(Boolean);
if (field.key === 'asrLanguage' && typeof value === 'string') value = value.toLowerCase().split(/[-_]/)[0];
if (['string', 'file'].includes(field.type) && typeof value === 'string') value = value.trim();
const valid = field.type === 'boolean' ? typeof value === 'boolean'
: field.type === 'number' ? Number.isFinite(value) && value >= field.min && value <= field.max && (field.step < 1 || Number.isInteger(value))
: field.type === 'choice' ? field.options.some(option => option.value === value)
: field.type === 'list' ? Array.isArray(value) && value.every(item => typeof item === 'string')
: typeof value === 'string' && value.length <= 4096;
if (!valid && strict) throw new Error(`Invalid value for ${field.title}`);
result[field.key] = valid ? value : field.default;
}
if (result.vadMinSpeechMs > result.vadMaxSpeechSeconds * 1000) throw new Error('Minimum speech must be shorter than the maximum recording');
return result;
}
export function mergeSettings(source, changes, fields) {
const merged = { ...source, ...changes };
// Remove obsolete aliases only for settings actually changed by this window.
for (const field of fields) if (Object.hasOwn(changes, field.key)) {
for (const alias of field.aliases || []) delete merged[alias];
}
return merged;
}
export function settingVisible(field, values) {
return !field.when || Object.entries(field.when).every(([key, choices]) => choices.includes(values[key]));
}
@@ -0,0 +1,136 @@
import Adw from 'gi://Adw';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Gtk from 'gi://Gtk';
import { SettingsEditor } from './settings-editor.js';
const BUS = 'io.qvac.Jarvis';
const PATH = '/io/qvac/Jarvis';
const IFACE = 'io.qvac.Jarvis.Session';
const OVERLAY_STYLES = ['tray', 'expanded'];
function entryRow(settings, title, key) {
const row = new Adw.EntryRow({ title });
row.set_text(settings.get_string(key));
row.connect('changed', () => settings.set_string(key, row.get_text()));
return row;
}
function strvRow(settings, title, key) {
const row = new Adw.EntryRow({ title });
row.set_text(settings.get_strv(key).join(', '));
row.connect('changed', () => {
const values = row.get_text().split(',').map((item) => item.trim()).filter(Boolean);
settings.set_strv(key, values);
});
return row;
}
function comboRow(settings, title, subtitle, key, values) {
const row = new Adw.ComboRow({ title, subtitle, model: Gtk.StringList.new(values) });
const current = settings.get_string(key);
row.selected = Math.max(0, values.indexOf(current));
row.connect('notify::selected', () => {
const value = values[row.selected];
if (value) settings.set_string(key, value);
});
return row;
}
function callDaemon(name, signature, values, onDone) {
Gio.DBus.session.call(
BUS,
PATH,
IFACE,
name,
signature ? GLib.Variant.new(signature, values) : null,
null,
Gio.DBusCallFlags.NONE,
4000,
null,
(_source, result) => {
try {
const reply = Gio.DBus.session.call_finish(result);
onDone?.(null, reply);
} catch (error) {
onDone?.(error);
}
},
);
}
function grantRow() {
const row = new Adw.ActionRow({
title: 'Desktop grant',
subtitle: 'Jarvis needs a temporary grant before observing or controlling the desktop.',
});
const allow = new Gtk.Button({ label: 'Allow now', valign: Gtk.Align.CENTER });
allow.add_css_class('suggested-action');
const revoke = new Gtk.Button({ label: 'Revoke', valign: Gtk.Align.CENTER });
revoke.add_css_class('destructive-action');
const refresh = () => {
callDaemon('ComputerStatus', null, null, (error, reply) => {
if (error) {
row.subtitle = 'Jarvis daemon is unavailable. Start jarvisd, then try Allow now.';
return;
}
let status = {};
try {
const unpacked = reply.deep_unpack?.() ?? reply.unpack?.();
const raw = Array.isArray(unpacked) ? unpacked[0] : unpacked;
status = JSON.parse(String(raw || '{}'));
} catch {}
if (status.active) {
row.subtitle = `Active. ${Number(status.steps_used) || 0} of ${Number(status.steps_max) || 20} steps used.`;
} else {
row.subtitle = 'Off. Press Allow now so Jarvis can observe or control the desktop for the configured grant duration.';
}
});
};
allow.connect('clicked', () => {
callDaemon('ComputerGrant', '(b)', [true], (error) => {
row.subtitle = error ? `Could not grant: ${error.message}` : 'Grant requested. A portal prompt may appear.';
refresh();
});
});
revoke.connect('clicked', () => {
callDaemon('ComputerRevoke', null, null, (error) => {
row.subtitle = error ? `Could not revoke: ${error.message}` : 'Grant revoked.';
refresh();
});
});
row.add_suffix(allow);
row.add_suffix(revoke);
row.activatable_widget = allow;
refresh();
return row;
}
export function fillSettingsWindow(window, settings, directory) {
window.set_title('Jarvis QVAC');
window.set_default_size(840, 780);
window.search_enabled = true;
const editor = new SettingsEditor(directory, settings);
const voice = editor.page(window, 'Voice', ['Speech', 'Voice design'], 'audio-speakers-symbolic', true);
window.add(voice);
const listening = editor.page(window, 'Listening', ['Listening', 'Wake and privacy', 'Detection tuning', 'Audio routing'], 'audio-input-microphone-symbolic');
window.add(listening);
const desktop = editor.page(window, 'Desktop', ['Desktop access', 'Desktop images'], 'preferences-desktop-display-symbolic');
const desktopGroup = new Adw.PreferencesGroup({ title: 'Overlay and shortcuts', description: 'These appearance settings take effect immediately.' });
desktopGroup.add(strvRow(settings, 'Hotkey', 'hotkey'));
desktopGroup.add(entryRow(settings, 'Accent color', 'accent-color'));
desktopGroup.add(comboRow(settings, 'Desktop layout', 'Tray keeps Jarvis in the top bar; expanded opens a conversation panel', 'overlay-style', OVERLAY_STYLES));
desktop.add(desktopGroup);
const grantGroup = new Adw.PreferencesGroup({ title: 'Temporary desktop access' });
grantGroup.add(grantRow()); desktop.add(grantGroup);
window.add(desktop);
const models = editor.page(window, 'Models', ['Chat model', 'Agent limits'], 'system-run-symbolic');
const service = new Adw.PreferencesGroup({ title: 'Apply chat model changes' });
const restart = new Adw.ActionRow({ title: 'Restart Jarvis', subtitle: 'Saves your changes and restarts the user service. Ends the current request and desktop grant.' });
const restartButton = new Gtk.Button({ label: 'Save & Restart', valign: Gtk.Align.CENTER });
restartButton.connect('clicked', async () => { restartButton.sensitive = false; try { await editor.restart(); } finally { restartButton.sensitive = true; } });
restart.add_suffix(restartButton); service.add(restart); models.add(service);
window.add(models);
return { editor, pages: [voice, listening, desktop, models] };
}
@@ -1,4 +1,7 @@
.jarvis-panel-glyph { color: var(--jarvis-accent, #F4B942); font-size: 16px; }
.jarvis-panel-box { spacing: 6px; }
.jarvis-panel-glyph { color: var(--jarvis-accent, #F4B942); font-size: 14px; }
.jarvis-panel-state { padding: 1px 8px; border-radius: 999px; background-color: rgba(244, 185, 66, .22); }
.jarvis-panel-state-label { color: var(--jarvis-accent, #F4B942); font-size: 12px; font-weight: bold; }
.jarvis-menu { max-width: 360px; }
.jarvis-menu-item { padding: 0; }
.jarvis-popup { width: 320px; padding: 12px 14px; spacing: 8px; color: #f6f7fb; }
@@ -33,8 +36,6 @@
.jarvis-settings { font-size: 12px; padding: 4px 8px; }
.jarvis-popup StEntry, .jarvis-session StEntry { border-radius: 10px; padding: 8px 10px; background-color: rgba(255, 255, 255, .06); color: #f6f7fb; border: 1px solid rgba(255, 255, 255, .15); }
.jarvis-popup StEntry:focus, .jarvis-session StEntry:focus { border-color: #F4B942; }
.jarvis-osd { padding: 8px 16px; border-radius: 999px; background-color: rgba(11, 14, 20, .9); border: 1px solid rgba(244, 185, 66, .45); }
.jarvis-osd-label { color: #f6f7fb; font-size: 13px; font-weight: bold; }
.jarvis-cu { padding: 8px 12px; spacing: 4px; border-radius: 12px; background-color: rgba(11, 14, 20, .82); border: 1px solid rgba(79, 210, 255, .4); }
.jarvis-job, .jarvis-target, .jarvis-cu-step { color: #4FD2FF; font-size: 12px; }
.jarvis-agent-cursor { color: var(--jarvis-accent, #F4B942); font-size: 22px; }
+37 -28
View File
@@ -250,41 +250,50 @@ export class ConversationView {
destroy() { this.root.destroy(); }
}
const PANEL_STATES = {
LISTENING: 'Listening',
SPEAKING: 'Speaking',
THINKING: 'Thinking',
SLEEPING: 'Privacy',
};
export class JarvisOsd {
constructor() {
this.root = new St.BoxLayout({ style_class: 'jarvis-osd', visible: false });
this.label = new St.Label({ text: '', style_class: 'jarvis-osd-label' });
this.root = new St.BoxLayout({ style_class: 'jarvis-panel-state', visible: false, y_align: Clutter.ActorAlign.CENTER });
this.label = new St.Label({ text: '', style_class: 'jarvis-panel-state-label', y_align: Clutter.ActorAlign.CENTER });
this.root.add_child(this.label);
this.root.accessible_name = 'Jarvis status overlay';
this._sticky = false;
this.root.accessible_name = 'Jarvis status';
this._state = 'ARMED';
this._wake = false;
this._timeout = 0;
}
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.hide(); }
_place() {
const monitor = Main.layoutManager.primaryMonitor;
if (!monitor) return;
const width = 220;
this.root.set_width(width);
this.root.set_position(monitor.x + Math.max(0, Math.round((monitor.width - width) / 2)), monitor.y + 36);
}
show(text, { sticky = false, ms = 2000 } = {}) {
this.label.text = safeText(text);
this._sticky = sticky;
this._place();
this.root.visible = true;
if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; }
const limit = sticky ? Math.min(ms || 8000, 8000) : ms;
this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, limit, () => { this._timeout = 0; this.hide(); return GLib.SOURCE_REMOVE; });
}
attach(parent) { parent?.add_child?.(this.root); this.hide(); }
setState(state) {
if (state === 'LISTENING') this.show('Listening', { sticky: true, ms: 8000 });
else if (state === 'SPEAKING') this.show('Speaking', { sticky: true, ms: 8000 });
else if (state === 'SLEEPING') this.show('Privacy mode', { sticky: false, ms: 1600 });
else this.hide();
this._state = STATES.has(state) ? state : 'ARMED';
this._wake = false;
this._clearTimer();
this._render();
}
showWake() { this.show('Wake', { sticky: false, ms: 1200 }); }
hide() { this.root.visible = false; this._sticky = false; if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } }
destroy() { this.hide(); this.root.destroy(); }
showWake() {
this._wake = true;
this._clearTimer();
this._render();
this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1200, () => {
this._timeout = 0;
this._wake = false;
this._render();
return GLib.SOURCE_REMOVE;
});
}
_render() {
const text = this._wake ? 'Wake' : (PANEL_STATES[this._state] || '');
this.label.text = text;
this.root.visible = Boolean(text);
this.root.accessible_name = text ? `Jarvis ${text}` : 'Jarvis status';
}
_clearTimer() { if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } }
hide() { this._wake = false; this._clearTimer(); this.root.visible = false; }
destroy() { this._clearTimer(); this.root.destroy(); }
}
export class ComputerUseChrome {
+3 -3
View File
@@ -3,10 +3,10 @@ import { assertSafeTarget, requiresConfirmation } from './safety.js';
export class ComputerActuator {
constructor({ session, input, atspiAction, find, highlight, audit, confirm = async () => false, sleep = delay, verify = async () => true } = {}) { this.session = session; this.input = input; this.atspiAction = atspiAction; this.find = find; this.highlight = highlight; this.audit = audit; this.confirm = confirm; this.sleep = sleep; this.verify = verify; }
async target(args = {}) { const target = args.ref && this.find ? (await this.find({ ref: args.ref }))[0] : args; if (target) assertSafeTarget(target); return target; }
async run(action, args, fn) { const target = await this.target(args); if (requiresConfirmation(action, target) && !(await this.confirm(action, target))) throw new Error('explicit confirmation required'); this.session.beginStep(); await this.highlight?.(target, action); try { const result = await fn(target); await this.sleep(150); if (!(await this.verify(target, action, result))) throw new Error('computer-use state did not change after action'); await this.audit?.record(action, target, { ok: true }); return { ok: true, action, target: target || null, result }; } catch (error) { await this.audit?.record(action, target, { ok: false, reason: error.message }); throw error; } }
async target(args = {}) { const target = args.ref && this.find ? (await this.find({ ref: args.ref }))[0] : args; if (args.ref && !target) throw new Error('unknown or stale computer-use ref'); if (target) assertSafeTarget(target); return target; }
async run(action, args, fn) { const target = await this.target(args); if (requiresConfirmation(action, target) && !(await this.confirm(action, target))) throw new Error('explicit confirmation required'); this.session.beginStep(); await this.highlight?.(target, action); this.session.assertActive(); try { const result = await fn(target); await this.sleep(150); if (!(await this.verify(target, action, result))) throw new Error('computer-use state did not change after action'); await this.audit?.record(action, target, { ok: true }); return { ok: true, action, target: target || null, result }; } catch (error) { await this.audit?.record(action, target, { ok: false, reason: error.message }); throw error; } }
async act({ ref, action }) { return this.run(`act:${action}`, { ref }, async (target) => { if (!this.atspiAction) throw new Error('AT-SPI action backend is unavailable'); return this.atspiAction(target, action); }); }
async click(args = {}) { return this.run('click', args, async (target) => { if (target && this.atspiAction) return this.atspiAction(target, 'click'); if (args.x == null || args.y == null) throw new Error('click requires a semantic ref or coordinates'); this.input.send({ type: 'pointer', action: 'click', x: args.x, y: args.y, button: args.button || 'left' }); }); }
async click(args = {}) { return this.run('click', args, async (target) => { if (args.ref && target && this.atspiAction) return this.atspiAction(target, 'click'); if (args.x == null || args.y == null) throw new Error('click requires a semantic ref or coordinates'); this.input.send({ type: 'pointer', action: 'click', x: args.x, y: args.y, button: args.button || 'left' }); }); }
async doubleClick(args = {}) { return this.run('double_click', args, async (target) => { if (args.x == null || args.y == null) throw new Error('double-click requires coordinates'); this.input.send({ type: 'pointer', action: 'double_click', x: args.x, y: args.y }); return target; }); }
async rightClick(args = {}) { return this.run('right_click', args, async () => { this.input.send({ type: 'pointer', action: 'click', x: args.x, y: args.y, button: 'right' }); }); }
async hover(args = {}) { return this.run('hover', args, async () => { this.input.send({ type: 'pointer', action: 'move', x: args.x, y: args.y }); }); }
+3 -3
View File
@@ -5,11 +5,11 @@ import { spawn } from 'node:child_process';
export const MAX_LONG_EDGE = 1280;
export class FrameNormalizer {
constructor({ helper = path.resolve(new URL('./py/normalize_frame.py', import.meta.url).pathname), python = 'python3', spawnImpl = spawn, tmpDir = '/tmp/jarvis-cu' } = {}) { this.helper = helper; this.python = python; this.spawnImpl = spawnImpl; this.tmpDir = tmpDir; }
constructor({ helper = path.resolve(new URL('./py/normalize_frame.py', import.meta.url).pathname), python = 'python3', spawnImpl = spawn, tmpDir = '/tmp/jarvis-cu', maxLongEdge = MAX_LONG_EDGE, quality = 70 } = {}) { this.helper = helper; this.python = python; this.spawnImpl = spawnImpl; this.tmpDir = tmpDir; this.maxLongEdge = maxLongEdge; this.quality = quality; }
async normalize(input, output = path.join(this.tmpDir, `frame-${Date.now()}.webp`), rect) {
await mkdir(path.dirname(output), { recursive: true });
const crop = rect ? rect.map(Number).map((value) => String(Math.round(value))) : [];
await new Promise((resolve, reject) => { const child = this.spawnImpl(this.python, [this.helper, input, output, String(MAX_LONG_EDGE), '70', ...crop], { stdio: ['ignore', 'pipe', 'pipe'] }); let error = ''; child.stderr?.on('data', (d) => { error += d; }); child.on('error', reject); child.on('close', (code) => code === 0 ? resolve() : reject(new Error(error || `frame normalization exited ${code}`))); });
const info = await stat(output); return { path: output, bytes: info.size, maxLongEdge: MAX_LONG_EDGE, mime: 'image/webp' };
await new Promise((resolve, reject) => { const child = this.spawnImpl(this.python, [this.helper, input, output, String(this.maxLongEdge), String(this.quality), ...crop], { stdio: ['ignore', 'pipe', 'pipe'] }); let error = ''; child.stderr?.on('data', (d) => { error += d; }); child.on('error', reject); child.on('close', (code) => code === 0 ? resolve() : reject(new Error(error || `frame normalization exited ${code}`))); });
const info = await stat(output); return { path: output, bytes: info.size, maxLongEdge: this.maxLongEdge, mime: 'image/webp' };
}
}
+3 -3
View File
@@ -20,10 +20,10 @@ export class DesktopObserver {
const [windows, focused, tree] = await Promise.all([this.shell.windows(), this.shell.focused(), includeTree ? this.tree().catch((error) => { unavailable.push(`AT-SPI: ${error.message}`); return []; }) : Promise.resolve([])]);
const result = { monitor: null, focused, windows: windows.windows || [], tree, screenshot_path: frame.path, ocr_blocks: [], vision_hint: null, unavailable };
if (!windows.available) result.unavailable.push(windows.reason);
if (includeOcr && this.ocr) result.ocr_blocks = await this.ocr(frame.path).catch((error) => { result.unavailable.push(`OCR: ${error.message}`); return []; });
if (includeVision && this.vision) result.vision_hint = await this.vision(frame.path).catch((error) => { result.unavailable.push(`vision: ${error.message}`); return null; });
if (frame.path && includeOcr && this.ocr) result.ocr_blocks = await this.ocr(frame.path).catch((error) => { result.unavailable.push(`OCR: ${error.message}`); return []; });
if (frame.path && includeVision && this.vision) result.vision_hint = await this.vision(frame.path).catch((error) => { result.unavailable.push(`vision: ${error.message}`); return null; });
return result;
}
async zoom({ rect, ref } = {}) { const node = ref ? this.lastTree.find((item) => item.ref === ref) : null; const target = rect || node?.rect; if (!target) throw new Error('rect or current tree ref is required'); const raw = await this.screenshot.capture(path.join(this.tmpDir, `zoom-${Date.now()}.png`)); const frame = await this.normalizer.normalize(raw, path.join(this.tmpDir, `zoom-${Date.now()}.webp`), target); return { rect: target, screenshot_path: frame.path }; }
async zoom({ rect, ref } = {}) { await mkdir(this.tmpDir, { recursive: true }); const node = ref ? this.lastTree.find((item) => item.ref === ref) : null; const target = rect || node?.rect; if (!target) throw new Error('rect or current tree ref is required'); const raw = await this.screenshot.capture(path.join(this.tmpDir, `zoom-${Date.now()}.png`)); const frame = await this.normalizer.normalize(raw, path.join(this.tmpDir, `zoom-${Date.now()}.webp`), target); return { rect: target, screenshot_path: frame.path }; }
async find({ query, role } = {}) { const nodes = this.lastTree.length ? this.lastTree : await this.tree(); return nodes.map((node) => ({ ...node, score: score(query, node) })).filter((node) => node.score && (!role || normalize(node.role) === normalize(role))).sort((a, b) => b.score - a.score).slice(0, 20); }
}
+44 -3
View File
@@ -4,8 +4,49 @@ import path from 'node:path';
/** Wayland input boundary. The helper owns portal consent and the EIS fd;
* Node sends only bounded JSON actions and never uses hidden uinput. */
export class PortalInputBackend {
constructor({ command = process.env.JARVIS_EI_HELPER || path.resolve(new URL('./py/portal_remote_desktop.py', import.meta.url).pathname), python = 'python3', spawnImpl = spawn } = {}) { this.command = command; this.python = python; this.spawnImpl = spawnImpl; this.process = null; this.available = false; }
async grant({ persist = false, monitors = 'focused' } = {}) { if (this.process) return { restore_token_present: Boolean(persist) }; this.process = this.spawnImpl(this.python, [this.command], { stdio: ['pipe', 'pipe', 'pipe'] }); let ready = ''; this.process.stdout.on('data', (data) => { ready += String(data); for (const line of ready.split(/\r?\n/).slice(0, -1)) { try { const event = JSON.parse(line); if (event.type === 'ready') { this.available = true; this._ready = event; } } catch {} } ready = ready.split(/\r?\n/).pop() || ''; }); await new Promise((resolve, reject) => { this.process.once('error', reject); const timer = setTimeout(resolve, 1500); this.process.stdout.once('data', () => { clearTimeout(timer); resolve(); }); }); return { restore_token_present: Boolean(persist), monitors, backend: this.available ? 'portal-ei' : 'none' }; }
constructor({ command = process.env.JARVIS_EI_HELPER || path.resolve(new URL('./py/portal_remote_desktop.py', import.meta.url).pathname), python = 'python3', spawnImpl = spawn, timeoutMs = 120_000 } = {}) {
this.command = command; this.python = python; this.spawnImpl = spawnImpl; this.timeoutMs = timeoutMs;
this.process = null; this.available = false; this._grant = null;
}
grant({ persist = false, monitors = 'focused' } = {}) {
if (this._grant) return this._grant;
const child = this.process = this.spawnImpl(this.python, [this.command], { stdio: ['pipe', 'pipe', 'pipe'] });
this._grant = new Promise((resolve, reject) => {
let buffer = ''; let settled = false;
const timer = setTimeout(() => fail(new Error('portal input consent timed out')), this.timeoutMs);
const fail = (error) => {
clearTimeout(timer);
if (this.process === child) { this.available = false; this.process = null; this._grant = null; }
if (!settled) { settled = true; reject(error); }
child.kill('SIGTERM');
};
this._cancelGrant = () => fail(new Error('portal input grant revoked'));
child.on('error', fail);
child.once('close', () => {
clearTimeout(timer);
if (this.process === child) { this.available = false; this.process = null; this._grant = null; }
if (!settled) { settled = true; reject(new Error('portal input helper exited before readiness')); }
});
child.stdin?.on('error', fail);
child.stderr?.on('data', () => {});
child.stdout.on('data', (data) => {
buffer += String(data);
if (buffer.length > 64 * 1024) { fail(new Error('portal helper output too large')); return; }
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
let event;
try { event = JSON.parse(line); } catch { continue; }
if (event.type === 'error') { fail(new Error(event.reason || 'portal input unavailable')); return; }
if (event.type === 'ready' && this.process === child && !settled) {
clearTimeout(timer); settled = true; this.available = true;
resolve({ restore_token_present: Boolean(event.restore_token_present), monitors, backend: 'portal-ei' });
}
}
});
});
return this._grant;
}
send(action) { if (!this.available || !this.process?.stdin?.writable) throw new Error('portal EIS input backend is unavailable'); this.process.stdin.write(`${JSON.stringify(action)}\n`); }
revoke() { this.available = false; this.process?.kill('SIGTERM'); this.process = null; }
revoke() { this._cancelGrant?.(); this._cancelGrant = null; this.available = false; this.process = null; this._grant = null; }
}
+14 -4
View File
@@ -1,8 +1,10 @@
const MAX_STEPS = 100;
export class ComputerUseSession {
constructor({ stepsMax = 20, clock = () => Date.now(), audit } = {}) {
constructor({ stepsMax = 20, grantMinutes = 3, mode = 'act', clock = () => Date.now(), audit } = {}) {
this.clock = clock;
this.mode = mode;
this.grantMinutes = grantMinutes;
this.stepsMax = Math.min(MAX_STEPS, Math.max(1, stepsMax));
this.active = false;
this.stepsUsed = 0;
@@ -13,10 +15,11 @@ export class ComputerUseSession {
}
grant({ persist = false, monitors = 'focused' } = {}) {
if (this.mode === 'off') throw new Error('Desktop access is disabled in Settings');
this.active = true;
this.stepsUsed = 0;
this.sessionId = `cu_${this.clock().toString(36)}`;
this.expiresAt = this.clock() + 3 * 60 * 1000;
this.expiresAt = this.clock() + this.grantMinutes * 60 * 1000;
return { session_id: this.sessionId, restore_token_present: Boolean(persist), monitors, backend: this.backend };
}
@@ -27,12 +30,14 @@ export class ComputerUseSession {
this.sessionId = null;
this.expiresAt = null;
this.backend = 'none';
void this.audit?.wipeTemp?.();
Promise.resolve(this.audit?.wipeTemp?.()).catch(() => {});
}
status() {
if (this.active && this.expiresAt != null && this.clock() >= this.expiresAt) this.revoke();
return {
active: this.active,
mode: this.mode,
steps_used: this.stepsUsed,
steps_max: this.stepsMax,
grant_expires_at: this.expiresAt,
@@ -40,12 +45,17 @@ export class ComputerUseSession {
};
}
beginStep() {
assertActive() {
if (!this.active) throw new Error('computer-use grant is inactive');
if (this.expiresAt && this.clock() >= this.expiresAt) {
this.revoke();
throw new Error('computer-use grant expired');
}
}
beginStep() {
this.assertActive();
if (this.mode !== 'act') throw new Error('Desktop access is observe-only');
if (this.stepsUsed >= this.stepsMax) throw new Error('computer-use step budget exhausted');
this.stepsUsed += 1;
}
+3 -2
View File
@@ -8,12 +8,13 @@ export const MIC_FORMAT = 's16';
/** Raw 16 kHz mono capture from PipeWire. The process is deliberately kept
* outside gnome-shell and has a stable node name for routing in Helvum. */
export class PipeWireCapture extends EventEmitter {
constructor({ command = 'pw-cat', spawnImpl = spawn, sampleRate = MIC_SAMPLE_RATE, nodeName = 'Jarvis' } = {}) {
constructor({ command = 'pw-cat', spawnImpl = spawn, sampleRate = MIC_SAMPLE_RATE, nodeName = 'Jarvis', target = '' } = {}) {
super();
this.command = command;
this.spawnImpl = spawnImpl;
this.sampleRate = sampleRate;
this.nodeName = nodeName;
this.target = target;
this.process = null;
}
@@ -21,7 +22,7 @@ export class PipeWireCapture extends EventEmitter {
if (this.process) return this;
this.process = this.spawnImpl(this.command, [
'--record', '--raw', '--format', MIC_FORMAT, '--rate', String(this.sampleRate),
'--channels', String(MIC_CHANNELS), '--properties', `node.name=${this.nodeName}`, '-',
'--channels', String(MIC_CHANNELS), ...(this.target ? ['--target', this.target] : []), '--properties', `node.name=${this.nodeName}`, '-',
], { stdio: ['ignore', 'pipe', 'pipe'] });
this.process.stdout?.on('data', (chunk) => this.emit('audio', Buffer.from(chunk)));
this.process.stderr?.on('data', (chunk) => this.emit('diagnostic', String(chunk).trim()));
+4 -4
View File
@@ -2,12 +2,12 @@ import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
export class PipeWirePlayback extends EventEmitter {
constructor({ command = 'pw-cat', spawnImpl = spawn, sampleRate = 44_100, nodeName = 'Jarvis' } = {}) {
super(); this.command = command; this.spawnImpl = spawnImpl; this.sampleRate = sampleRate; this.nodeName = nodeName; this.process = null;
constructor({ command = 'pw-cat', spawnImpl = spawn, sampleRate = 44_100, nodeName = 'Jarvis', target = '' } = {}) {
super(); this.command = command; this.spawnImpl = spawnImpl; this.sampleRate = sampleRate; this.nodeName = nodeName; this.target = target; this.process = null;
}
async play(samples) {
async play(samples, sampleRate = this.sampleRate) {
this.stop();
const child = this.process = this.spawnImpl(this.command, ['--playback', '--raw', '--format', 's16', '--rate', String(this.sampleRate), '--channels', '1', '--properties', `node.name=${this.nodeName}`, '-'], { stdio: ['pipe', 'ignore', 'pipe'] });
const child = this.process = this.spawnImpl(this.command, ['--playback', '--raw', '--format', 's16', '--rate', String(sampleRate), '--channels', '1', ...(this.target ? ['--target', this.target] : []), '--properties', `node.name=${this.nodeName}`, '-'], { stdio: ['pipe', 'ignore', 'pipe'] });
let diagnostic = '';
child.stderr?.on('data', (chunk) => { diagnostic = (diagnostic + String(chunk)).slice(-2048); this.emit('diagnostic', String(chunk).trim()); });
await new Promise((resolve, reject) => {
+6
View File
@@ -24,6 +24,9 @@ export async function serveOnSessionBus(daemon) {
PushToTalk(pressed) { daemon.setPushToTalk?.(Boolean(pressed)); }
async Say(text) { await daemon.say?.(text); }
async Ask(text) { await daemon.ask(text); }
ReloadSettings() { return daemon.reloadSettings(); }
PreviewVoice(text) { return daemon.previewVoice(text); }
StopSpeech() { daemon.stopSpeech(); }
async ResetContext() { await daemon.resetContext(); }
Confirm(jobId, toolCallId, decision) { daemon.confirmPermission?.(jobId, toolCallId, decision); }
Cancel() { daemon.cancel(); }
@@ -64,6 +67,9 @@ export async function serveOnSessionBus(daemon) {
Session.configureMembers({
methods: {
Arm: { inSignature: '', outSignature: '' },
ReloadSettings: { inSignature: '', outSignature: 's' },
PreviewVoice: { inSignature: 's', outSignature: '' },
StopSpeech: { inSignature: '', outSignature: '' },
Sleep: { inSignature: '', outSignature: '', method: 'Sleep' },
Shutdown: { inSignature: '', outSignature: '', method: 'Shutdown' },
PushToTalk: { inSignature: 'b', outSignature: '', method: 'PushToTalk' },
+2 -2
View File
@@ -23,8 +23,8 @@ try {
process.exitCode = 1;
} finally {
// Resource discovery initializes QVAC handles even without loading a model.
// This one-shot command owns them and must close them before returning to
// first-run. Bound cleanup in case a native handle never settles.
// This one-shot command owns them and must close them before returning.
// Bound cleanup in case a native handle never settles.
const watchdog = setTimeout(() => {
console.error('gpu-doctor: QVAC cleanup timed out');
process.exit(1);
+14 -10
View File
@@ -1,5 +1,6 @@
import { voiceSettings } from './voice-settings.js';
import { EventEmitter } from 'node:events';
import { acquireQvac, closeQvac, releaseQvac, Agent } from './qvac-master.js';
import { acquireQvac, closeQvac, releaseQvac, Agent, QVAC_MASTER } from './qvac-master.js';
import { createRuntimeTools } from '../skills/runtime-tools.js';
import { createPhase2Tools } from '../skills/phase2-tools.js';
import { createQvacTools } from '../skills/qvac-tools.js';
@@ -9,8 +10,9 @@ import { createComputerActTools } from '../skills/computer-act.js';
import { createPhase9GatewayTool } from '../skills/phase9-tools.js';
export class HarnessBridge extends EventEmitter {
constructor({ cwd = process.cwd(), model = 'qwen3.5-4b', tools = [], computer, observer, actuator, permissionMode = 'ask' } = {}) {
constructor({ cwd = process.cwd(), model = QVAC_MASTER.model, tools = [], computer, observer, actuator, permissionMode = 'ask' } = {}) {
super();
const settings = voiceSettings();
this.options = {
cwd,
model,
@@ -29,9 +31,9 @@ export class HarnessBridge extends EventEmitter {
origin: 'jarvis-qvac',
system: VOICE_SYSTEM_PROMPT,
voice: true,
maxTurns: 6,
maxShellCalls: 1,
maxToolRounds: 4,
maxTurns: settings.maxTurns,
maxShellCalls: settings.maxShellCalls,
maxToolRounds: settings.maxToolRounds,
};
this.session = null;
}
@@ -42,10 +44,12 @@ export class HarnessBridge extends EventEmitter {
throw new Error(`Jarvis uses one QVAC master model (${process.env.JARVIS_QVAC_MODEL}); requested ${this.options.model}`);
}
await acquireQvac();
this._acquired = true;
try {
this.session = await Agent.create(this.options);
} catch (error) {
releaseQvac();
this._acquired = false;
await closeQvac();
throw error;
}
@@ -93,14 +97,14 @@ export class HarnessBridge extends EventEmitter {
cancel() { this.session?.cancel(); }
async resetContext() {
this.session?.cancel?.();
await this.session?.dispose?.();
try { this.session?.cancel?.(); await this.session?.dispose?.(); }
finally {
this.session = null;
if (this._acquired) { releaseQvac(); this._acquired = false; }
}
}
async close() {
await this.session?.dispose();
releaseQvac();
await closeQvac();
try { await this.resetContext(); } finally { await closeQvac(); }
}
}
+89 -20
View File
@@ -1,3 +1,8 @@
import { PipeWireCapture } from './audio-pipewire.js';
import { PipeWirePlayback } from './audio-playback.js';
import { VadSegmenter } from './vad.js';
import { FrameNormalizer } from '../computer-use/frame.js';
import { ttsConfiguration } from './tts-config.js';
import { EventEmitter } from 'node:events';
import { HarnessBridge } from './harness-bridge.js';
import { ComputerUseSession } from '../computer-use/session.js';
@@ -22,13 +27,15 @@ export class JarvisDaemon extends EventEmitter {
constructor() {
super();
this.recovery = new StateRecovery(); const restored = this.recovery.load(); this.state = restored.state; this.mode = restored.mode;
this.voice = new VoiceStateMachine();
this.settings = voiceSettings();
this.startupSettings = this.settings;
this.voice = new VoiceStateMachine({ idleMs: this.settings.idleMinutes * 60_000 });
this.scheduler = new QvacScheduler({ concurrency: 1 });
this.audit = new ComputerAudit();
this.computer = new ComputerUseSession({ audit: this.audit });
this.computer = new ComputerUseSession({ audit: this.audit, stepsMax: this.settings.computerSteps, grantMinutes: this.settings.computerGrantMinutes, mode: this.settings.computerMode });
this.input = new PortalInputBackend();
this.perception = new QvacPerception();
this.observer = new DesktopObserver({ ocr: (image) => this.perception.ocr(image) });
this.observer = new DesktopObserver({ normalizer: new FrameNormalizer({ maxLongEdge: this.settings.screenshotMaxEdge, quality: this.settings.screenshotQuality }), ocr: (image) => this.perception.ocr(image) });
this.actuator = new ComputerActuator({ session: this.computer, input: this.input, find: ({ ref }) => this.observer.lastTree.filter((node) => node.ref === ref), atspiAction: (target, action) => this.observer.atspi.action(target, action), highlight: async (target, action) => this.emit('ComputerHighlight', JSON.stringify({ rect: target?.rect || null, label: `${action} ${target?.name || ''}` })) , audit: this.audit });
this.harness = new HarnessBridge({ cwd: process.cwd(), computer: this.computer, observer: this.observer, actuator: this.actuator });
this.log = new PrivacyLog();
@@ -36,6 +43,7 @@ export class JarvisDaemon extends EventEmitter {
this.lastReply = '';
this.voiceLoop = null;
this._activeAsk = null;
this._askGeneration = 0;
this._idleTimer = setInterval(() => this.tickIdle(), 30_000);
this._idleTimer.unref?.();
this.telemetry = new RuntimeTelemetry();
@@ -50,7 +58,7 @@ export class JarvisDaemon extends EventEmitter {
setState(state) { this.state = state; try { this.recovery.save({ state, mode: this.mode }); } catch (error) { this.emit('Error', 'RECOVERY_WRITE', error.message); } this.emit('StateChanged', state); this.log.record('state', { state }).catch(() => {}); }
async arm() { if (this.locked) return; await resumeQvac().catch(() => {}); this.voice.wake(); this.setState('LISTENING'); }
async sleep() { this.voice.sleep(); this.setState('SLEEPING'); await suspendQvac().catch((error) => this.emit('Error', 'QVAC_SUSPEND', error.message)); }
async sleep() { this.cancel(); this.voice.sleep(); this.setState('SLEEPING'); await suspendQvac().catch((error) => this.emit('Error', 'QVAC_SUSPEND', error.message)); }
say(text) {
const spoken = spokenReply(text) || spokenReply(this.lastReply) || 'There is nothing to repeat.';
this.lastReply = spoken;
@@ -59,6 +67,9 @@ export class JarvisDaemon extends EventEmitter {
this._speakReply(spoken);
}
async ask(text) {
if (this.locked) throw new Error('Jarvis is locked');
if (this._settingsReload) throw new Error('Settings are being applied; try again shortly');
const generation = ++this._askGeneration;
this.voiceLoop?.interrupt?.();
try {
this.voice.typedUtterance(); this.setState('THINKING');
@@ -66,17 +77,20 @@ export class JarvisDaemon extends EventEmitter {
const job = this.scheduler.run(() => this.harness.ask(text), { lane: 'voice' });
this._activeAsk = job;
const reply = await job;
if (generation !== this._askGeneration || this.locked) return '';
this.telemetry.record('llm', startedAt, { success: true });
const spoken = spokenReply(reply);
if (!spoken) { this.setState('LISTENING'); return ''; }
if (!spoken) { this._finishSpeech(); return ''; }
this._beginSpeech(); this.lastReply = spoken; this.emit('Reply', spoken); this._speakReply(spoken); return spoken;
} catch (error) {
if (generation !== this._askGeneration) return '';
this.telemetry.record('llm', Date.now(), { success: false }); this.emit('Error', 'QVAC', error.message); this.voice.cancel(); this.setState('ARMED'); throw error;
} finally {
this._activeAsk = null;
if (generation === this._askGeneration) this._activeAsk = null;
}
}
async resetContext() {
this._askGeneration += 1;
this.harness.cancel();
this.scheduler.cancelQueued((job) => job.lane === 'voice');
await this._activeAsk?.catch?.(() => {});
@@ -99,15 +113,18 @@ export class JarvisDaemon extends EventEmitter {
}
_finishSpeech() {
try { this.voice.finishSpeaking(); } catch {}
if (this.state === 'SPEAKING') this.setState('LISTENING');
if (this.settings.listeningMode !== 'conversation') { this.voice.cancel(); this.setState('ARMED'); }
else this.setState('LISTENING');
}
_speakReply(spoken) {
const generation = this._askGeneration;
if (!this.voiceLoop) {
this._finishSpeech();
return;
}
const play = async () => {
await this.ensureTts();
if (generation !== this._askGeneration || this.locked) return;
if (!this.voiceLoop?.status?.tts) {
const reason = this.voiceLoop?.status?.errors?.tts;
if (reason && reason !== 'Spoken replies are disabled') this.emit('Error', 'TTS', reason);
@@ -119,14 +136,15 @@ export class JarvisDaemon extends EventEmitter {
.catch((error) => {
this.emit('Error', 'TTS', error.message);
})
.finally(() => this._finishSpeech());
.finally(() => { if (generation === this._askGeneration) this._finishSpeech(); });
}
async ensureTts() {
await this._voiceStarting;
if (!this.voiceLoop) return;
const settings = voiceSettings();
if (!settings.ttsEnabled) return;
const settings = this.settings;
if (!settings.ttsEnabled) { this.voiceLoop.status.tts = false; return; }
if (this.voiceLoop.status.tts) return;
if (!this.voiceLoop.tts) this.voiceLoop.tts = new QvacVoiceAdapter({ role: 'tts' });
if (!this.voiceLoop.tts) this.voiceLoop.tts = new QvacVoiceAdapter({ role: 'tts', settings });
try {
await this.voiceLoop.tts.start();
this.voiceLoop.status.tts = true;
@@ -139,16 +157,26 @@ export class JarvisDaemon extends EventEmitter {
this.emit('Error', 'TTS', message);
}
}
cancel() { this.voiceLoop?.interrupt?.(); this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computer.revoke(); this.voice.cancel(); this.setState('ARMED'); cancelQvac().catch((error) => this.emit('Error', 'QVAC_CANCEL', error.message)); }
computerGrant(persist = false) { const result = this.computer.grant({ persist }); this.emit('ComputerStep', JSON.stringify({ action: 'grant', ...result })); this.input.grant({ persist }).then((backend) => { this.computer.setBackend(backend.backend); this.emit('ComputerStep', JSON.stringify({ action: 'backend', ...backend })); }).catch((error) => this.emit('Error', 'CU_GRANT', error.message)); return result; }
cancel() { this._askGeneration += 1; this.voiceLoop?.interrupt?.(); this.harness.cancel(); this.scheduler.cancelQueued((job) => job.lane === 'voice'); this.computerRevoke(); this.voice.cancel(); this.setState('ARMED'); cancelQvac().catch((error) => this.emit('Error', 'QVAC_CANCEL', error.message)); }
computerGrant(persist = false) { const result = this.computer.grant({ persist }); this.emit('ComputerStep', JSON.stringify({ action: 'grant', ...result })); if (this.settings.computerMode === 'observe') return result; this.input.grant({ persist }).then((backend) => { this.computer.setBackend(backend.backend); this.emit('ComputerStep', JSON.stringify({ action: 'backend', ...backend })); }).catch((error) => this.emit('Error', 'CU_GRANT', error.message)); return result; }
computerRevoke() { this.input.revoke(); this.computer.revoke(); this.emit('ComputerStep', JSON.stringify({ action: 'revoke' })); }
async startVoice() {
startVoice() {
if (this._voiceStarting) return this._voiceStarting;
this._voiceStarting = this._startVoice().finally(() => { this._voiceStarting = null; });
return this._voiceStarting;
}
async _startVoice() {
if (this.voiceLoop) return;
const settings = voiceSettings();
const asr = new QvacVoiceAdapter({ role: 'asr' });
const tts = settings.ttsEnabled ? new QvacVoiceAdapter({ role: 'tts' }) : null;
const settings = this.settings;
const asr = settings.microphoneEnabled ? new QvacVoiceAdapter({ role: 'asr', settings }) : null;
const tts = settings.ttsEnabled ? new QvacVoiceAdapter({ role: 'tts', settings }) : null;
if (!settings.ttsEnabled) console.log('jarvisd: voice: TTS disabled in config.json');
const loop = new VoiceLoop({ daemon: this, wake: createWakeEngine(settings), asr, tts });
const loop = new VoiceLoop({ daemon: this, wake: createWakeEngine(settings), asr, tts,
capture: new PipeWireCapture({ target: settings.inputTarget }),
playback: new PipeWirePlayback({ target: settings.outputTarget }),
vad: new VadSegmenter({ params: { threshold: settings.vadThreshold, minSpeechDurationMs: settings.vadMinSpeechMs, minSilenceDurationMs: settings.vadSilenceMs, maxSpeechDurationMs: settings.vadMaxSpeechSeconds * 1000 } }),
cooldownMs: settings.playbackCooldownMs, listeningMode: settings.listeningMode,
});
loop.on('error', (error) => { console.error(`jarvisd: voice: ${error.message}`); this.emit('Error', 'VOICE', error.message); });
this.voiceLoop = loop;
try { await loop.start(); this.emit('StateChanged', this.state); } catch (error) {
@@ -157,15 +185,56 @@ export class JarvisDaemon extends EventEmitter {
if (loop.status.tts) console.log('jarvisd: voice: TTS ready');
else if (loop.status.errors.tts) console.error(`jarvisd: voice: TTS: ${loop.status.errors.tts}`);
}
async reloadSettings() {
if (this._settingsReload) return this._settingsReload;
this._settingsReload = this._reloadSettings().finally(() => { this._settingsReload = null; });
return this._settingsReload;
}
async _reloadSettings() {
if (this._activeAsk) throw new Error('Wait for the current request to finish before applying settings');
const next = voiceSettings(null, { strict: true });
if (next.ttsEnabled) ttsConfiguration(next);
await this._voiceStarting;
const previous = this.settings;
this._askGeneration += 1;
await this.voiceLoop?.stop?.();
this.voiceLoop = null;
this.settings = next;
this.voice.idleMs = next.idleMinutes * 60_000;
if (['computerMode', 'computerSteps', 'computerGrantMinutes'].some(key => next[key] !== previous[key])) this.computerRevoke();
Object.assign(this.computer, { mode: next.computerMode, stepsMax: next.computerSteps, grantMinutes: next.computerGrantMinutes });
Object.assign(this.observer.normalizer, { maxLongEdge: next.screenshotMaxEdge, quality: next.screenshotQuality });
await this.startVoice();
this.voice.cancel(); this.setState('ARMED');
return JSON.stringify({ applied: true, voice: this.voiceLoop.status, environmentOverrides: ['JARVIS_TTS_MODEL', 'JARVIS_ASR_MODEL', 'JARVIS_WAKE_COMMAND', 'JARVIS_QVAC_MODEL'].filter(key => process.env[key]), restartRequired: ['chatModel', 'modelProfile', 'maxTurns', 'maxShellCalls', 'maxToolRounds'].filter(key => next[key] !== this.startupSettings[key]) });
}
async previewVoice(text) {
if (this.locked) throw new Error('Unlock the desktop to preview a voice');
if (this._activeAsk) throw new Error('Wait for the current request to finish before previewing');
await this._settingsReload;
await this.startVoice(); await this.ensureTts();
if (!this.voiceLoop?.status.tts) throw new Error(this.voiceLoop?.status.errors.tts || 'Enable spoken replies to preview a voice');
const preview = String(text || this.settings.previewText).slice(0, 1000);
const generation = this._askGeneration;
this._beginSpeech();
try { await this.voiceLoop.speak(preview); } finally { if (generation === this._askGeneration) this._finishSpeech(); }
}
stopSpeech() { this.voiceLoop?.interrupt?.(); this._finishSpeech(); }
setPushToTalk(pressed) { this.voiceLoop?.setPushToTalk(pressed); this.emit('PushToTalk', Boolean(pressed)); }
runtimeStatus() { return JSON.stringify({ local: true, qvac: qvacStatus(), scheduler: this.scheduler.status(), scheduler_metrics: this.scheduler.metrics(), telemetry: this.telemetry.snapshot(), computer: this.computer.status(), voice: this.voiceLoop ? { ...this.voiceLoop.metrics.snapshot(), ...this.voiceLoop.status } : null, p2p: { enabled: process.env.JARVIS_P2P_ENABLE === '1', inference: false, memorySync: false } }); }
runtimeStatus() { return JSON.stringify({ local: true, qvac: qvacStatus(), scheduler: this.scheduler.status(), scheduler_metrics: this.scheduler.metrics(), telemetry: this.telemetry.snapshot(), settings: this.settings, computer: this.computer.status(), voice: this.voiceLoop ? { ...this.voiceLoop.metrics.snapshot(), ...this.voiceLoop.status } : null, p2p: { enabled: process.env.JARVIS_P2P_ENABLE === '1', inference: false, memorySync: false } }); }
async assessModelFit(model) { return JSON.stringify(await callQvac('assessModelFit', { modelSrc: String(model) })); }
async downloadModel(model) { return JSON.stringify(await callQvac('downloadAsset', { modelSrc: String(model) })); }
async cancelModel(model) { return JSON.stringify(await cancelQvacRequest({ modelId: String(model) })); }
async wipeComputerTraces() { await this.audit.wipeTemp(); return true; }
handleLockScreen(locked) { this.locked = Boolean(locked); if (this.locked) { this.cancel(); this.setState('ARMED'); } this.emit('LockScreenChanged', this.locked); }
tickIdle() { if (!this.locked && this.voice.expireIdle() === 'SLEEPING' && this.state !== 'SLEEPING') this.sleep(); }
async close() { clearInterval(this._idleTimer); clearInterval(this._telemetryTimer); this.computerRevoke(); this.recovery.save({ state: 'ARMED', mode: this.mode }); await this.voiceLoop?.stop?.(); await this.harness.close(); }
async close() {
clearInterval(this._idleTimer); clearInterval(this._telemetryTimer);
this.cancel();
try { this.recovery.save({ state: 'ARMED', mode: this.mode }); }
catch (error) { this.emit('Error', 'RECOVERY_WRITE', error.message); }
try { await this.voiceLoop?.stop?.(); } finally { await this.harness.close(); }
}
}
export async function startDaemon() {
+9 -4
View File
@@ -8,22 +8,27 @@ const MAX_LINE = 64 * 1024;
export async function createEventSocket({ socketPath, onMessage }) {
await mkdir(path.dirname(socketPath), { recursive: true });
try { await rm(socketPath, { force: true }); } catch {}
const clients = new Set();
const server = net.createServer((socket) => {
clients.add(socket);
socket.on('error', () => {});
socket.once('close', () => clients.delete(socket));
const invalid = () => { if (!socket.destroyed) socket.write(JSON.stringify({ error: 'invalid IPC message' }) + '\n'); };
let buffer = '';
socket.setEncoding('utf8');
socket.on('data', (chunk) => {
buffer += chunk;
if (buffer.length > MAX_LINE * 2) { socket.destroy(new Error('IPC buffer too large')); return; }
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
if (line.length > MAX_LINE) { socket.destroy(new Error('IPC message too large')); return; }
try { onMessage?.(JSON.parse(line), socket); } catch { socket.write(JSON.stringify({ error: 'invalid IPC message' }) + '\n'); }
if (Buffer.byteLength(line) > MAX_LINE) { socket.destroy(new Error('IPC message too large')); return; }
try { Promise.resolve(onMessage?.(JSON.parse(line), socket)).catch(invalid); } catch { invalid(); }
}
if (Buffer.byteLength(buffer) > MAX_LINE) socket.destroy(new Error('IPC message too large'));
});
});
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(socketPath, resolve); });
return { server, socketPath, close: () => new Promise((resolve) => server.close(() => fs.unlink(socketPath, () => resolve()))) };
return { server, socketPath, close: () => new Promise((resolve) => { for (const client of clients) client.destroy(); server.close(() => fs.unlink(socketPath, () => resolve())); }) };
}
export function sendEvent(socket, event, payload = {}) {
+1 -1
View File
@@ -1 +1 @@
export function assertLocalEndpoint(url) { const parsed = new URL(url); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('only HTTP(S) model endpoints are supported'); if (!['127.0.0.1', 'localhost', '::1'].includes(parsed.hostname) && process.env.JARVIS_P2P_ENABLE !== '1') throw new Error('network access is disabled unless optional P2P/model fetch is explicitly enabled'); return parsed; }
export function assertLocalEndpoint(url) { const parsed = new URL(url); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('only HTTP(S) model endpoints are supported'); if (!['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname) && process.env.JARVIS_P2P_ENABLE !== '1') throw new Error('network access is disabled unless optional P2P/model fetch is explicitly enabled'); return parsed; }
+17 -23
View File
@@ -1,3 +1,5 @@
import { voiceSettings } from './voice-settings.js';
import { profile } from './model-profiles.js';
import { createRequire } from 'node:module';
import fs from 'node:fs';
import path from 'node:path';
@@ -17,7 +19,7 @@ const auxiliaryModels = new Map();
export const QVAC_MASTER = Object.freeze({
configPath: process.env.QVAC_CONFIG_PATH,
model: process.env.JARVIS_QVAC_MODEL || 'qwen3.5-4b',
model: process.env.JARVIS_QVAC_MODEL || voiceSettings().chatModel || profile(voiceSettings().modelProfile).model,
device: 'gpu',
gpuLayers: 99,
});
@@ -45,34 +47,26 @@ export function assertSdkVersion() {
export async function acquireQvac({ auxiliaryOnly = false } = {}) {
if (auxiliaryOnly) { await qvacSdk(); ownerCount += 1; return; }
ownerCount += 1;
if (!loadPromise) {
// Publish the promise before the first await so concurrent callers share
// resource discovery and loading. Count only successful acquisitions.
loadPromise = Promise.resolve().then(async () => {
assertSdkVersion();
const resources = await Agent.engine.resources();
const gpuVisible = (resources.gpus?.length || 0) > 0 ||
Boolean(resources.drivers?.vulkan || resources.drivers?.cuda || resources.drivers?.opencl || resources.gpu);
if (!gpuVisible) {
ownerCount = Math.max(0, ownerCount - 1);
throw new Error('Jarvis requires a QVAC-visible GPU backend; run npm run gpu-doctor');
}
loadPromise = Agent.engine.load({
model: QVAC_MASTER.model,
tools: true,
device: 'gpu',
gpu_layers: QVAC_MASTER.gpuLayers,
mmprojUseGpu: true,
}).then((loaded) => {
if (loaded.device !== 'gpu') {
throw new Error(`Jarvis requires GPU QVAC inference; loaded device was ${loaded.device}`);
}
return loaded;
}).catch((error) => {
loadPromise = null;
ownerCount = Math.max(0, ownerCount - 1);
throw error;
if (!gpuVisible) throw new Error('Jarvis requires a QVAC-visible GPU backend; run npm run gpu-doctor');
const loaded = await Agent.engine.load({
model: QVAC_MASTER.model, tools: true, device: 'gpu',
gpu_layers: QVAC_MASTER.gpuLayers, mmprojUseGpu: true,
});
if (loaded.device !== 'gpu') throw new Error(`Jarvis requires GPU QVAC inference; loaded device was ${loaded.device}`);
return loaded;
}).catch((error) => { loadPromise = null; throw error; });
}
return loadPromise;
const loaded = await loadPromise;
ownerCount += 1;
return loaded;
}
export async function qvacSdk() {
@@ -92,7 +86,7 @@ function resolveSdkAsset(sdk, name) {
function resolveModelConfigAssets(sdk, config) {
const copy = { ...config };
for (const key of ['vadModelSrc', 'projectionModelSrc', 'vocabModelSrc']) {
for (const key of ['vadModelSrc', 'projectionModelSrc', 'vocabModelSrc', 's3genModelSrc']) {
if (typeof copy[key] === 'string') copy[key] = resolveSdkAsset(sdk, copy[key]);
}
return copy;
+34
View File
@@ -0,0 +1,34 @@
// Model configuration fields are checked against the installed QVAC 0.19 SDK.
export const TTS_PRESETS = Object.freeze({
'supertonic-en': { model: 'TTS_EN_SUPERTONIC_Q8_0', engine: 'supertonic', sampleRate: 44100 },
supertonic3: { model: 'TTS_MULTILINGUAL_SUPERTONIC3_Q8_0', engine: 'supertonic', sampleRate: 44100 },
chatterbox: { model: 'TTS_T3_TURBO_EN_CHATTERBOX_Q8_0', engine: 'chatterbox', sampleRate: 24000 },
parler: { model: 'TTS_MINI_V1_EN_PARLER_TTS_Q8_0', engine: 'parler', sampleRate: 44100 },
});
export function ttsConfiguration(settings) {
const preset = TTS_PRESETS[settings.ttsPreset];
if (!preset) throw new Error('Unknown speech model preset');
const config = { ttsEngine: preset.engine, useGPU: settings.ttsUseGpu };
if (preset.engine === 'supertonic') {
Object.assign(config, { language: settings.ttsPreset === 'supertonic3' ? settings.ttsLanguage : 'en', voice: settings.voiceId, ttsSpeed: settings.ttsSpeed, ttsNumInferenceSteps: settings.ttsSteps });
} else {
Object.assign(config, { threads: settings.ttsThreads, seed: settings.ttsSeed });
if (preset.engine === 'chatterbox') {
Object.assign(config, { language: 'en', s3genModelSrc: 'TTS_S3GEN_EN_CHATTERBOX', cfmSteps: settings.ttsCfmSteps });
if (settings.ttsReferenceAudio) config.referenceAudioSrc = settings.ttsReferenceAudio;
} else {
if (!settings.ttsDescription) throw new Error('Describe the Parler voice before applying settings');
Object.assign(config, { description: settings.ttsDescription, temperature: settings.ttsTemperature });
}
}
return { model: settings.ttsModel || preset.model, sampleRate: preset.sampleRate, config };
}
export function scaleSpeech(samples, volume = 100) {
if (volume === 100) return samples;
const output = new Int16Array(samples.length);
const gain = Math.max(0, Math.min(100, volume)) / 100;
for (let i = 0; i < samples.length; i++) output[i] = Math.round(samples[i] * gain);
return output;
}
+5 -4
View File
@@ -20,7 +20,7 @@ export class VadSegmenter extends EventEmitter {
this.reset();
}
reset() { this.speaking = false; this.buffer = []; this.speechMs = 0; this.silenceMs = 0; }
reset() { this.speaking = false; this.buffer = []; this.speechMs = 0; this.silenceMs = 0; this.recordingMs = 0; }
push(frame) {
const chunk = Buffer.from(frame || '');
@@ -34,11 +34,12 @@ export class VadSegmenter extends EventEmitter {
}
if (!this.speaking) return;
this.buffer.push(chunk);
this.recordingMs += durationMs;
if (voiced) { this.speechMs += durationMs; this.silenceMs = 0; }
else { this.silenceMs += durationMs; }
if (this.speechMs >= this.params.maxSpeechDurationMs ||
(this.speechMs >= this.params.minSpeechDurationMs && this.silenceMs >= this.params.minSilenceDurationMs)) {
this.end();
if (this.recordingMs >= this.params.maxSpeechDurationMs || this.silenceMs >= this.params.minSilenceDurationMs) {
if (this.speechMs >= this.params.minSpeechDurationMs) this.end();
else this.reset();
}
}
+12 -5
View File
@@ -1,3 +1,5 @@
import { voiceSettings } from './voice-settings.js';
import { ttsConfiguration, scaleSpeech } from './tts-config.js';
import { EventEmitter } from 'node:events';
import { acquireQvac, releaseQvac, loadAuxiliaryModel, unloadAuxiliaryModel, qvacSdk, withQvacMaster, assertSdkVersion } from './qvac-master.js';
@@ -20,8 +22,11 @@ function toUint8(samples) {
}
export class QvacVoiceAdapter extends EventEmitter {
constructor({ role = 'both', asrModel = process.env.JARVIS_ASR_MODEL || 'WHISPER_TINY', ttsModel = process.env.JARVIS_TTS_MODEL || 'TTS_EN_SUPERTONIC_Q8_0' } = {}) {
super(); this.role = role; this.asrModel = asrModel; this.ttsModel = ttsModel; this.asrId = null; this.ttsId = null; this.asrSession = null; this.acquired = false;
constructor({ role = 'both', settings = voiceSettings(), asrModel = settings.asrModel, ttsModel } = {}) {
super(); this.role = role; this.settings = settings;
this.ttsConfig = role === 'asr' ? null : ttsConfiguration(settings);
this.asrModel = asrModel; this.ttsModel = ttsModel || this.ttsConfig?.model;
this.asrId = null; this.ttsId = null; this.asrSession = null; this.acquired = false;
}
async start() {
@@ -29,8 +34,8 @@ export class QvacVoiceAdapter extends EventEmitter {
assertSdkVersion();
await acquireQvac({ auxiliaryOnly: true }); this.acquired = true;
try {
if (this.role !== 'tts') this.asrId = await loadAuxiliaryModel(this.asrModel, { vadModelSrc: 'VAD_SILERO_5_1_2', audio_format: 's16le', language: 'en', no_timestamps: true, vad_params: { threshold: 0.6, min_speech_duration_ms: 300, min_silence_duration_ms: 700, max_speech_duration_s: 15, speech_pad_ms: 200 } }, 'whispercpp-transcription');
if (this.role !== 'asr') this.ttsId = await loadAuxiliaryModel(this.ttsModel, { ttsEngine: 'supertonic', language: 'en', voice: 'F1', ttsSpeed: 1.05, ttsNumInferenceSteps: 5 }, 'tts-ggml');
if (this.role !== 'tts') this.asrId = await loadAuxiliaryModel(this.asrModel, { vadModelSrc: 'VAD_SILERO_5_1_2', audio_format: 's16le', language: this.settings.asrLanguage, no_timestamps: true, vad_params: { threshold: this.settings.vadThreshold, min_speech_duration_ms: this.settings.vadMinSpeechMs, min_silence_duration_ms: this.settings.vadSilenceMs, max_speech_duration_s: this.settings.vadMaxSpeechSeconds, speech_pad_ms: 200 } }, 'whispercpp-transcription');
if (this.role !== 'asr') this.ttsId = await loadAuxiliaryModel(this.ttsModel, this.ttsConfig.config, 'tts-ggml');
} catch (error) { await this.stop(); throw error; }
}
@@ -58,7 +63,9 @@ export class QvacVoiceAdapter extends EventEmitter {
}
return result;
});
return pcmS16le(samples, 44_100);
const audio = pcmS16le(samples, this.ttsConfig.sampleRate);
audio.samples = scaleSpeech(audio.samples, this.settings.ttsVolume);
return audio;
}
async stop() {
+9 -2
View File
@@ -1,3 +1,5 @@
import { voiceSettings } from './voice-settings.js';
import { ttsConfiguration } from './tts-config.js';
import { access } from 'node:fs/promises';
const commandAvailable = async (command) => {
@@ -7,13 +9,18 @@ const commandAvailable = async (command) => {
return false;
};
const pipewire = await commandAvailable('pw-cat');
const settings = voiceSettings();
const report = {
pipewireCapture: pipewire,
wakeEngine: Boolean(process.env.JARVIS_WAKE_COMMAND),
wakeCommand: process.env.JARVIS_WAKE_COMMAND || null,
wakeEngine: settings.listeningMode !== 'ptt' && Boolean(settings.command),
wakeCommand: settings.command || null,
sampleRate: 16000,
channels: 1,
ttsPlayback: pipewire,
microphoneEnabled: settings.microphoneEnabled,
inputTarget: settings.inputTarget || 'system default',
outputTarget: settings.outputTarget || 'system default',
speech: settings.ttsEnabled ? ttsConfiguration(settings) : 'disabled',
gpuRequired: true,
};
console.log(JSON.stringify(report, null, 2));
+64 -15
View File
@@ -20,18 +20,23 @@ const FAST_COMMANDS = new Map([
export function fastCommand(text) { return FAST_COMMANDS.get(String(text || '').trim().toLowerCase()) || null; }
export class VoiceLoop extends EventEmitter {
constructor({ daemon, capture = new PipeWireCapture(), playback = new PipeWirePlayback(), wake = new WakeEngine(), vad = new VadSegmenter(), asr, tts, cooldownMs = POST_PLAYBACK_COOLDOWN_MS, now = () => Date.now() } = {}) {
super(); this.daemon = daemon; this.capture = capture; this.playback = playback; this.wake = wake; this.vad = vad; this.asr = asr; this.tts = tts; this.cooldownMs = cooldownMs; this.now = now;
this.isSpeaking = false; this.cooldownUntil = 0; this.running = false; this.ptt = false; this._generation = 0; this._speechQueue = Promise.resolve(); this.metrics = new VoiceMetrics();
constructor({ daemon, capture = new PipeWireCapture(), playback = new PipeWirePlayback(), wake = new WakeEngine(), vad = new VadSegmenter(), asr, tts, cooldownMs = POST_PLAYBACK_COOLDOWN_MS, listeningMode = 'conversation', now = () => Date.now(), captureRetryMs = 2000, asrRetryMs = 4000 } = {}) {
super(); this.daemon = daemon; this.capture = capture; this.playback = playback; this.wake = wake; this.vad = vad; this.asr = asr; this.tts = tts; this.cooldownMs = cooldownMs; this.listeningMode = listeningMode; this.now = now;
this.captureRetryMs = captureRetryMs; this.asrRetryMs = asrRetryMs;
this.isSpeaking = false; this.cooldownUntil = 0; this.running = false; this.ptt = false; this._generation = 0; this._speechQueue = Promise.resolve(); this.metrics = new VoiceMetrics(); this._transcriptions = new Set();
capture.on('audio', (chunk) => this.pushAudio(chunk));
capture.on('error', (error) => { this.status.capture = false; this.status.errors.capture = error.message; this.emit('error', error); });
capture.on('close', () => { this.status.capture = false; if (this.running) { this.status.errors.capture = 'Microphone stream closed'; this.emit('error', new Error('Microphone stream closed')); } });
capture.on('error', (error) => { this.status.capture = false; this.status.errors.capture = error.message; this.emit('error', error); this._scheduleCaptureRetry(); });
capture.on('close', () => { this.status.capture = false; if (this.running) { this.status.errors.capture = 'Microphone stream closed'; this._scheduleCaptureRetry(); } });
wake.on('unavailable', () => { this.status.wake = false; });
wake.on('error', (error) => { this.status.wake = false; this.emit('error', error); });
this.status = { asr: false, tts: false, capture: false, wake: false, errors: {} };
wake.on('wake', (phrase) => this.wakeHeard(phrase));
vad.on('level', (rms) => daemon?.emit('ListeningLevel', rms));
vad.on('utterance', (audio) => this.transcribe(audio).catch((error) => this.emit('error', error)));
vad.on('utterance', (audio) => {
const task = this.transcribe(audio).catch((error) => this.emit('error', error));
this._transcriptions.add(task);
task.finally(() => this._transcriptions.delete(task)).catch(() => {});
});
}
async start() {
@@ -48,20 +53,62 @@ export class VoiceLoop extends EventEmitter {
}
this.running = true;
if (this.status.asr) {
try { this.capture.start(); this.status.capture = true; }
catch (error) { this.status.errors.capture = error.message; this.emit('error', error); }
try { this.wake.start?.(); this.wake.resume(); this.status.wake = Boolean(this.wake.command || this.wake.detect); }
this._armCapture();
this._armWake();
} else if (this.asr) {
this._scheduleAsrRetry();
}
}
_notifyStatus() { try { this.daemon?.emit?.('StateChanged', this.daemon.state || 'ARMED'); } catch {} }
_armWake() {
try { if (this.listeningMode !== 'ptt') this.wake.start?.(); this.wake.resume(); this.status.wake = this.listeningMode !== 'ptt' && Boolean(this.wake.command || this.wake.detect); }
catch (error) { this.status.errors.wake = error.message; this.emit('error', error); }
}
_armCapture() {
if (!this.running || !this.status.asr || this.status.capture) return;
try { this.capture.start(); this.status.capture = true; delete this.status.errors.capture; this._notifyStatus(); }
catch (error) { this.status.errors.capture = error.message; this.emit('error', error); this._scheduleCaptureRetry(); }
}
_scheduleCaptureRetry() {
if (!this.running || this._captureRetry || this.status.capture || !this.status.asr) return;
this._captureRetry = setTimeout(() => {
this._captureRetry = 0;
this._armCapture();
}, this.captureRetryMs);
this._captureRetry.unref?.();
}
_scheduleAsrRetry() {
if (!this.running || this._asrRetry || this.status.asr || !this.asr) return;
this._asrRetry = setTimeout(() => {
this._asrRetry = 0;
if (!this.running || this.status.asr || !this.asr) return;
this.asr.start().then(() => {
if (!this.running || this.status.asr) return;
this.status.asr = true;
delete this.status.errors.asr;
this._armCapture();
this._armWake();
this._notifyStatus();
}).catch((error) => {
this.status.errors.asr = String(error?.message || error);
this._scheduleAsrRetry();
});
}, this.asrRetryMs);
this._asrRetry.unref?.();
}
async stop() {
this.running = false;
if (this._captureRetry) { clearTimeout(this._captureRetry); this._captureRetry = 0; }
if (this._asrRetry) { clearTimeout(this._asrRetry); this._asrRetry = 0; }
this.interrupt(); this.capture.stop(); this.wake.close(); this.vad.reset(); this.playback.stop(); await Promise.allSettled([this._speechQueue, ...this._transcriptions]); await this.asr?.stop?.(); if (this.tts !== this.asr) await this.tts?.stop?.();
}
async stop() { this.running = false; this.capture.stop(); this.wake.close(); this.vad.reset(); this.playback.stop(); await this.asr?.stop?.(); if (this.tts !== this.asr) await this.tts?.stop?.(); }
setPushToTalk(pressed) { this.ptt = Boolean(pressed) && this.status.asr && this.status.capture; if (this.ptt) this.wakeHeard('push-to-talk'); else this.vad.end(); }
pushAudio(chunk) {
if (!this.running || this.daemon?.locked || this.daemon?.state === 'SLEEPING') return;
if (this.isSpeaking || this.now() < this.cooldownUntil) { this.metrics.feedbackDrop(); return; }
try { this.daemon?.emit('ListeningLevel', Math.min(1, Math.max(0, chunk?.length ? 0.01 : 0))); } catch {}
if (!this.ptt) this.wake.push(chunk);
if (this.ptt || this.daemon?.state === 'LISTENING') this.vad.push(chunk);
if (!this.ptt && this.listeningMode !== 'ptt') this.wake.push(chunk);
if (this.ptt || (this.listeningMode !== 'ptt' && this.daemon?.state === 'LISTENING')) this.vad.push(chunk);
}
wakeHeard(phrase) {
if (!this.running || this.daemon?.locked) return;
@@ -69,8 +116,9 @@ export class VoiceLoop extends EventEmitter {
}
async transcribe(audio) {
if (!this.status.asr || !this.asr?.transcribeAudio) return;
const generation = this._generation;
const text = await this.asr.transcribeAudio(audio).catch((error) => { this.emit('error', error); return ''; });
if (!this.running || this.daemon?.locked) return;
if (!this.running || this.daemon?.locked || generation !== this._generation) return;
if (!isMeaningfulTranscript(text)) { this.metrics.wakeRejected(); return; }
this.metrics.utterance();
this.daemon?.emit('PartialTranscript', text); this.daemon?.emit('FinalTranscript', text);
@@ -93,7 +141,8 @@ export class VoiceLoop extends EventEmitter {
this.wake.resume();
if (this.daemon?.state === 'SPEAKING') {
try { this.daemon.voice?.finishSpeaking?.(); } catch {}
this.daemon.setState?.('LISTENING');
if (this.daemon._finishSpeech) this.daemon._finishSpeech();
else this.daemon.setState?.('LISTENING');
}
}
async speak(text) {
@@ -127,7 +176,7 @@ export class VoiceLoop extends EventEmitter {
try {
const audio = await this.tts.speak(speakableForTts(text));
if (generation !== this._generation) return;
await this.playback.play(audio.samples);
await this.playback.play(audio.samples, audio.sampleRate);
try { this.daemon?.emit('SpeakingLevel', 0); } catch {}
} finally {
this.isSpeaking = false;
+16 -17
View File
@@ -1,25 +1,24 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { normalizeSettings } from '../apps/gnome-extension/[email protected]/settings-values.js';
function flag(value, fallback = true) {
if (value === undefined || value === null) return fallback;
if (typeof value === 'string') return !['false', '0', 'off', 'no'].includes(value.trim().toLowerCase());
return value !== false;
export const SETTINGS_FIELDS = JSON.parse(fs.readFileSync(new URL('../apps/gnome-extension/[email protected]/settings-catalog.json', import.meta.url).pathname, 'utf8')).fields;
export const configPath = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'jarvis/config.json');
export function readSettings({ strict = false } = {}) {
try {
const config = JSON.parse(fs.readFileSync(configPath(), 'utf8'));
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Settings must be a JSON object');
return config;
} catch (error) { if (strict && error.code !== 'ENOENT') throw error; return {}; }
}
export function voiceSettings(source = null) {
let config = source;
if (!config) {
config = {};
try { config = JSON.parse(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'jarvis/config.json'), 'utf8')); } catch {}
}
const phrase = config.wakePhrase || config.wake_phrase || 'hey jarvis';
const aliases = Array.isArray(config.aliases) ? config.aliases : ['jarvis', 'okay jarvis'];
export function voiceSettings(source = null, { strict = false } = {}) {
const settings = normalizeSettings(source ?? readSettings({ strict }), SETTINGS_FIELDS, { strict });
return {
command: process.env.JARVIS_WAKE_COMMAND || config.wakeCommand || config.wake_command || '',
phrases: [phrase, ...aliases].map((item) => String(item || '').trim()).filter(Boolean),
ttsEnabled: flag(config.ttsEnabled ?? config.tts_enabled, true),
modelProfile: config.modelProfile || config.model_profile || 'laptop-16gb',
...settings,
command: process.env.JARVIS_WAKE_COMMAND || settings.wakeCommand,
phrases: [settings.wakePhrase, ...settings.aliases].map(item => item.trim()).filter(Boolean),
asrModel: process.env.JARVIS_ASR_MODEL || settings.asrModel,
ttsModel: process.env.JARVIS_TTS_MODEL || settings.ttsModel,
};
}
+1 -1
View File
@@ -48,7 +48,7 @@ export class VoiceStateMachine {
}
expireIdle() {
if (this.state !== 'SLEEPING' && this.now() - this.lastActivity >= this.idleMs) this.sleep();
if (['ARMED', 'LISTENING'].includes(this.state) && this.now() - this.lastActivity >= this.idleMs) this.sleep();
return this.state;
}
}
+3
View File
@@ -5,6 +5,9 @@
<method name="PushToTalk"><arg name="pressed" type="b" direction="in"/></method>
<method name="Say"><arg name="text" type="s" direction="in"/></method>
<method name="Ask"><arg name="text" type="s" direction="in"/></method>
<method name="ReloadSettings"><arg type="s" direction="out"/></method>
<method name="PreviewVoice"><arg name="text" type="s" direction="in"/></method>
<method name="StopSpeech"/>
<method name="ResetContext"/>
<method name="Confirm"><arg name="jobId" type="s" direction="in"/><arg name="toolCallId" type="s" direction="in"/><arg name="decision" type="s" direction="in"/></method>
<method name="Cancel"/><method name="SetMode"><arg name="mode" type="s" direction="in"/></method>
+10
View File
@@ -24,3 +24,13 @@ hand.
The uninstall command requires `--yes` and preserves data. Add
`--purge-data` only when the user explicitly wants Jarvis-owned memory, cache,
and generated media removed.
## Shared settings and configurable speech
The GNOME preferences and standalone Control Center now share one window.
Daemon controls use `config.json`; only appearance and shortcuts use GSettings.
Opening preferences no longer copies GSettings defaults over JSON values.
Legacy snake_case settings and explicit GSettings customizations migrate when
you press Apply. See [settings](settings.md) for keys, application timing,
model compatibility, and controls that were removed because they had no
working backend.
+2
View File
@@ -55,3 +55,5 @@ must be verified on the target GNOME session.
- `jarvisd` means the user service and its packaged Bare process.
- “Master” means the singleton in `daemon/qvac-master.js`.
- Mermaid diagrams use Gitea-compatible fenced Markdown.
- [Settings and voice customization](settings.md)
+1 -1
View File
@@ -8,7 +8,7 @@
2. Run `npm ci`, `npm test`, and `npm run package:test`.
3. Review `dist/SHA256SUMS` and test the user installer in a disposable user
session with `packaging/install.sh --enable`.
4. Run the typed first-run smoke test and the computer-use acceptance flow.
4. Send a typed `Ask` over the session bus and run the computer-use acceptance flow.
## Gitea rolling release
+1 -1
View File
@@ -43,7 +43,7 @@ flowchart TB
| --- | --- | --- |
| GNOME Shell extension | panel, ARC overlay, highlights, Shell facts, D-Bus client | model loading, microphone capture, input injection, blocking work |
| `jarvisd` | voice state, QVAC master, harness bridge, jobs, D-Bus service | arbitrary Shell evaluation or a second planner |
| Control Center | settings, model fit, permissions, first-run controls | inference loop or hidden data deletion |
| Control Center | settings, model fit, permissions | inference loop or hidden data deletion |
| Computer-use helpers | portal sessions, frames, AT-SPI, EIS/legacy backend | model planning or lock-screen bypass |
| Agent harness | session history, planning, memory, tool loop, permissions | direct QVAC ownership or direct desktop hacks |
| QVAC master | SDK worker, GPU admission, lifecycle, serialization, cancellation | cloud APIs or independent model instances |
+2 -3
View File
@@ -14,12 +14,11 @@ On a logged-in GNOME session with a visible QVAC GPU backend:
```bash
bash packaging/install.sh --enable
bash packaging/first-run.sh
systemctl --user status jarvisd.service
gnome-extensions enable jarvis@qvac.local
```
Complete the typed prompt first, then say “Hey Jarvis, whats on my screen?”
and approve a computer-use grant only for the acceptance actions in
Complete the typed prompt from the tray, then say “Hey Jarvis, whats on my
screen?” and approve a computer-use grant only for the acceptance actions in
`docs/cu-acceptance.md`. If the doctor reports no GPU, stop at diagnostics and
fix the QVAC runtime/driver path before attempting inference.
+180
View File
@@ -0,0 +1,180 @@
# Settings and voice customization
Open **Settings** in the Jarvis tray or conversation header. The standalone
Control Center (`python3 apps/control-center/main.py`) uses the same pages.
Search is available in the window header.
## Choosing a voice
1. On **Voice**, enable **Spoken replies** and choose a speech model.
2. For Supertonic, choose a voice (F1F5 or M1M5), then adjust speed,
quality steps, and volume. Start at speed 1.01.05 and 5 steps.
3. Edit **Preview text** and press **Apply & Preview**. **Stop** interrupts
playback. The preview does not add a chat message or replace the last reply.
4. Use **Reset this page** to restore defaults in the form, then Apply.
Models offered by the installed QVAC SDK:
- **Supertonic English:** the existing lightweight default. Uses English.
- **Supertonic 3:** multilingual speech with a language picker. Its registry
model supports the 31 languages listed in the window.
- **Chatterbox Turbo:** English speech with an optional local reference voice
recording. Choose a clear mono WAV of at least five seconds. Leave it empty
for the model's default voice. Supertonic voice IDs and speed do not apply.
- **Parler Mini:** English speech controlled by a written voice description.
Describe pitch, tone, pace, and recording style. Variation, seed, CPU threads,
and GPU acceleration are available. It does not use Supertonic voice IDs.
The preset voice IDs follow the [official Supertonic voice list](https://github.com/supertone-inc/supertonic/blob/main/web/README.md).
Older GGUF bundles may contain fewer voices. Unsupported voices or unavailable
models produce an error in Settings rather than silently falling back.
The presets and configuration fields were checked against the installed
`@qvac/inference` 0.19.1 schemas, model registry, and `@qvac/tts-ggml` 0.8.1
implementation. Each engine's native audio rate is used for playback (24 kHz
for Chatterbox, 44.1 kHz for Supertonic and Parler).
## Saving and applying
**Apply** saves the form and reloads voice, listening, audio routing, and desktop
settings. A running chat request must finish first. Loading a new model may
require a download and take time; the status row reports progress or errors.
Already downloaded assets use the QVAC cache. The preview requires spoken
replies to be enabled.
Chat model and agent-limit changes require **Models → Save & Restart**.
This saves pending changes and restarts `jarvisd.service`, ending current work.
If the service is not installed or D-Bus is unavailable, settings remain saved;
the status explains what still needs applying. The daemon also reads them on
its next launch. Appearance and hotkey changes apply immediately through GNOME.
Settings live in `$XDG_CONFIG_HOME/jarvis/config.json`, defaulting to
`~/.config/jarvis/config.json`. Opening a window does not write to this file.
Saving re-reads it and merges only changed fields, preserving unrelated keys
and changes from another window. Invalid JSON is reported and not overwritten.
Legacy snake_case keys and explicitly customized voice GSettings are migrated;
the JSON file takes precedence over GSettings. New writes use the keys below.
`JARVIS_TTS_MODEL`, `JARVIS_ASR_MODEL`, `JARVIS_WAKE_COMMAND`, and
`JARVIS_QVAC_MODEL` environment variables take precedence over the form. Apply
reports active overrides. The custom speech model must match the selected
engine; an arbitrary model does not change the engine automatically.
## Listening and desktop behavior
**Microphone input** disables capture entirely when off. **Hold Talk only**
keeps automatic wake and follow-up listening off; **One request per wake**
returns to the armed state after a reply. A wake phrase needs a configured
local detector trained to recognize it. Changing the text alone does not train
or install a detector.
Microphone and speaker pickers list PipeWire devices by name. An empty value
means the system default. Refresh after plugging in a device. Speech detection
controls adjust quiet-speech sensitivity, pause timing, minimum speech, maximum
recording duration, and post-playback echo protection.
Desktop mode is enforced: Disabled refuses grants, Observe only refuses input,
and Observe and control permits input within an explicit temporary grant.
Changing mode, duration, or budget revokes existing access. Screenshot dimensions
and WebP quality tune observation detail and processing cost.
## Complete daemon setting reference
### Speech
- **Spoken replies**`ttsEnabled`, default `true`. Read replies aloud using local speech synthesis.
- **Speech model**`ttsPreset`, default `"supertonic-en"`. Models download on first use. Larger models need more memory. Choices: `supertonic-en`, `supertonic3`, `chatterbox`, `parler`.
- **Voice**`voiceId`, default `"F1"`. Try the same preview with different voices. Available voices depend on the model bundle. Choices: `F1`, `F2`, `F3`, `F4`, `F5`, `M1`, `M2`, `M3`, `M4`, `M5`.
- **Speech language**`ttsLanguage`, default `"en"`. The English models always use English. Choose Supertonic 3 for other languages. Choices: `en`, `ko`, `ja`, `ar`, `bg`, `cs`, `da`, `de`, `el`, `es`, `et`, `fi`, `fr`, `hi`, `hr`, `hu`, `id`, `it`, `lt`, `lv`, `nl`, `pl`, `pt`, `ro`, `ru`, `sk`, `sl`, `sv`, `tr`, `uk`, `vi`.
- **Speaking speed**`ttsSpeed`, default `1.05`. 1 is normal speed; lower is slower. Range: 0.254.
- **Voice quality steps**`ttsSteps`, default `5`. More steps can improve quality but take longer. Range: 130.
- **Reply volume (%)**`ttsVolume`, default `100`. Relative to the system speaker volume. Does not change other apps. Range: 0100.
- **Preview text**`previewText`, default `"Hello. I am Jarvis. This is how I will sound with your settings."`. Use the same sentence to compare voices.
### Voice design
- **Reference voice recording**`ttsReferenceAudio`, default `""`. Choose a clear mono WAV recording of at least 5 seconds. Leave empty for the models default voice.
- **Synthesis quality steps**`ttsCfmSteps`, default `2`. Chatterbox: fewer steps respond faster. Range: 110.
- **Describe the voice**`ttsDescription`, default `"A clear, warm voice speaks at a natural pace in a quiet room."`. Parler: describe tone, pace, pitch, and recording style in English.
- **Voice variation**`ttsTemperature`, default `1`. Parler: higher values produce more variation. Range: 02.
- **Voice seed**`ttsSeed`, default `42`. Use a fixed seed for repeatable speech generation. Range: 02147483647.
- **Speech CPU threads**`ttsThreads`, default `4`. Limit CPU work for Chatterbox and Parler. Range: 132.
- **Accelerate speech with GPU**`ttsUseGpu`, default `false`. Use a supported GPU backend for speech synthesis. May increase GPU memory use.
- **Custom speech model**`ttsModel`, default `""`. Advanced: registry name or local GGUF matching the selected speech model. Empty uses the bundled preset.
### Listening
- **Microphone input**`microphoneEnabled`, default `true`. Turn off for typed chat and speech output only.
- **Recognition model**`asrModel`, default `"WHISPER_TINY"`. Larger models need more memory and download on first use. Choices: `WHISPER_TINY`, `WHISPER_BASE_Q8_0`, `WHISPER_SMALL_Q8_0`.
- **Recognition language**`asrLanguage`, default `"en"`. Whisper language code such as en, es, fr, or auto.
### Wake and privacy
- **Wake phrase**`wakePhrase`, default `"hey jarvis"`. Must match a phrase supported by your local wake detector.
- **Wake aliases**`aliases`, default `["jarvis", "okay jarvis"]`. Additional detector phrases, separated by commas.
- **Wake detector command**`wakeCommand`, default `""`. Advanced: local program that receives microphone audio. Leave empty to use Hold Talk.
- **Listening behavior**`listeningMode`, default `"conversation"`. Hold Talk only disables the wake detector and automatic follow-up listening. Choices: `conversation`, `single`, `ptt`.
- **Idle sleep delay (minutes)**`idleMinutes`, default `30`. Sleep after this much listening inactivity. Range: 1240.
### Detection tuning
- **Speech detection threshold**`vadThreshold`, default `0.6`. Lower picks up quieter speech; higher rejects more background noise. Range: 0.051.
- **Minimum speech (ms)**`vadMinSpeechMs`, default `300`. Ignore very short sounds during automatic listening. Range: 1002000.
- **Pause before sending (ms)**`vadSilenceMs`, default `700`. Wait this long after speech before sending your request. Range: 2003000.
- **Maximum recording (seconds)**`vadMaxSpeechSeconds`, default `15`. Limit each recorded utterance, including pauses. Range: 3120.
- **Echo protection after replies (ms)**`playbackCooldownMs`, default `400`. Delay microphone processing after speech output ends. Range: 02000.
### Audio routing
- **Microphone device**`inputTarget`, default `""`. System default follows your desktop sound settings. Refresh the list after connecting a device.
- **Speaker device**`outputTarget`, default `""`. System default follows your desktop sound settings. Refresh the list after connecting a device.
### Desktop access
- **Desktop access mode**`computerMode`, default `"act"`. A temporary Allow now grant is always required. Changing this revokes existing access. Choices: `off`, `observe`, `act`.
- **Actions per grant**`computerSteps`, default `20`. Maximum input actions before another grant is needed. Range: 1100.
- **Grant duration (minutes)**`computerGrantMinutes`, default `3`. Desktop access expires automatically. Range: 115.
### Desktop images
- **Screenshot maximum edge (pixels)**`screenshotMaxEdge`, default `1280`. Larger screenshots preserve detail but take more memory. Range: 6402560.
- **Screenshot quality (%)**`screenshotQuality`, default `70`. Higher WebP quality preserves more text detail. Range: 3095.
### Chat model
- **Chat model profile**`modelProfile`, default `"laptop-16gb"`. Requires a daemon restart. GPU inference remains required. Choices: `laptop-8gb`, `laptop-16gb`, `desktop-gpu`.
- **Custom chat model**`chatModel`, default `""`. Advanced: overrides the profile. Empty uses the profile model. Requires restart.
### Agent limits
- **Maximum reasoning turns**`maxTurns`, default `6`. Requires restart. Limits how long the assistant works on one request. Range: 130.
- **Shell commands per request**`maxShellCalls`, default `1`. Requires restart. Commands still require normal permissions. Range: 110.
- **Tool rounds per request**`maxToolRounds`, default `4`. Requires restart. Limits repeated tool use. Range: 120.
## GNOME appearance and boundaries
The Desktop page also exposes the GNOME hotkey, accent color, and tray versus
expanded layout. These retain their GSettings keys: `hotkey`, `accent-color`,
and `overlay-style`.
Previously displayed controls for chimes, legacy input fallback, enrollment,
startup, memory ingestion, Lab toggles, transcript retention, and disabling
confirmations did not consistently have working consumers. They are not
presented as working controls in the shared window. Their stored values are
preserved. Confirmations remain enforced; desktop mode is not a permission
bypass. CosyVoice3 and Audio8 need additional component and reference-text
configuration and are not offered as presets in this change.
## Verification
- `npm test`: migration, merging, validation, installed SDK schemas and registry,
adapter forwarding, volume, device routing, listening modes, VAD limits,
grant enforcement, preview behavior, and settings reload.
- `bash packaging/bare-launch.sh packaging/bare-run.js scripts/smoke-settings-runtime.js`:
validates the settings-to-adapter path under Bare without loading models.
- `XDG_CONFIG_HOME=/tmp/jarvis-settings-smoke GSETTINGS_BACKEND=memory gjs -m scripts/smoke-settings-ui.js`:
opens all four pages, saves page images under `/tmp`, checks engine-specific
control visibility, and closes. It does not save or apply settings.
Live audio quality, model downloads, and GPU support depend on the host and
selected model; the automated schema checks do not establish those results.
-1
View File
@@ -26,7 +26,6 @@
"package": "bash packaging/build-release.sh",
"package:bare": "bash packaging/build-runtime-bundle.sh",
"package:test": "bash packaging/test-package.sh",
"first-run": "bash packaging/first-run.sh",
"install:user": "bash packaging/install.sh",
"uninstall:user": "bash packaging/uninstall.sh",
"bare:smoke": "bash packaging/bare-launch.sh packaging/bare-smoke.js"
+1 -1
View File
@@ -13,7 +13,7 @@ Version: ${VERSION}
Section: utils
Priority: optional
Architecture: ${ARCH}
Depends: pipewire-bin, python3-gi, gir1.2-gtk-4.0, gir1.2-adw-1, gnome-shell (>= 45)
Depends: gjs, pipewire-bin, python3-gi, gir1.2-gtk-4.0, gir1.2-adw-1 (>= 1.4), gnome-shell (>= 45)
Maintainer: JARVIS-QVAC maintainers
Description: Local GPU-backed GNOME voice assistant
JARVIS-QVAC is a local-first voice assistant for Ubuntu GNOME.
-86
View File
@@ -1,86 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
case "$(uname -m)" in
x86_64|amd64) BARE="${ROOT_DIR}/node_modules/bare-runtime-linux-x64/bin/bare" ;;
aarch64|arm64) BARE="${ROOT_DIR}/node_modules/bare-runtime-linux-arm64/bin/bare" ;;
*) BARE="" ;;
esac
BARE="${BARE:-$(command -v bare || true)}"
[[ -x "${BARE}" ]] || { echo "Packaged Bare runtime not found; reinstall the rolling bundle." >&2; exit 1; }
echo "JARVIS-QVAC first-run setup"
echo "The daemon is GPU-gated and keeps QVAC behind one master owner."
echo
echo "[1/5] GPU and QVAC preflight"
"${BARE}" packaging/bare-run.js daemon/gpu-doctor.js || true
echo
echo "[2/5] Microphone and PipeWire preflight"
"${BARE}" packaging/bare-run.js daemon/voice-doctor.js || true
echo
echo "[3/5] Wake phrase and model profile"
read -r -p "Wake phrase [hey jarvis]: " WAKE_PHRASE
WAKE_PHRASE="${WAKE_PHRASE:-hey jarvis}"
read -r -p "Model profile [laptop-8gb/laptop-16gb/desktop-gpu] (default: laptop-16gb): " MODEL_PROFILE
MODEL_PROFILE="${MODEL_PROFILE:-laptop-16gb}"
case "${MODEL_PROFILE}" in
laptop-8gb|laptop-16gb|desktop-gpu) ;;
*) echo "Unknown model profile: ${MODEL_PROFILE}" >&2; exit 2 ;;
esac
read -r -p "Play a TTS preview now? [Y/n]: " PREVIEW_CHOICE
PLAY_PREVIEW=true
[[ "${PREVIEW_CHOICE:-Y}" =~ ^[Nn]$ ]] && PLAY_PREVIEW=false
# Spoken replies stay on. Answering n only skips the one-shot preview; it does
# not write ttsEnabled=false into config.json.
TTS_ENABLED=true
mkdir -p "${HOME}/.config/jarvis"
"${BARE}" packaging/write-config.js "${HOME}/.config/jarvis/config.json" "${WAKE_PHRASE}" "${MODEL_PROFILE}" "${TTS_ENABLED}"
echo "Saved wake phrase: ${WAKE_PHRASE}; model profile: ${MODEL_PROFILE}; TTS: ${TTS_ENABLED}"
echo
# The daemon owns the session D-Bus name. Start it after writing the config and
# wait for that name before sending the preview or smoke-test request.
DAEMON_BUS_READY=false
if command -v systemctl >/dev/null && command -v busctl >/dev/null && [[ -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
systemctl --user daemon-reload || true
systemctl --user enable jarvisd.service || true
systemctl --user restart jarvisd.service || true
for _ in {1..30}; do
if busctl --user status io.qvac.Jarvis >/dev/null 2>&1; then
DAEMON_BUS_READY=true
break
fi
sleep 0.5
done
fi
echo "[4/5] TTS preview"
if [[ "${PLAY_PREVIEW}" == true && "${DAEMON_BUS_READY}" == true ]]; then
busctl --user call io.qvac.Jarvis /io/qvac/Jarvis io.qvac.Jarvis.Session Say s "JARVIS local voice preview"
elif [[ "${PLAY_PREVIEW}" == true ]]; then
echo "Daemon did not register io.qvac.Jarvis; preview skipped."
else
echo "TTS preview skipped; spoken replies remain enabled."
fi
echo
echo "[5/5] Typed smoke test"
read -r -p "Type a test prompt (default: say hello): " PROMPT
PROMPT="${PROMPT:-say hello}"
if [[ "${DAEMON_BUS_READY}" == true ]]; then
SMOKE_TIMEOUT="${JARVIS_SMOKE_TIMEOUT:-180}"
if busctl --timeout="${SMOKE_TIMEOUT}" --user call io.qvac.Jarvis /io/qvac/Jarvis io.qvac.Jarvis.Session Ask s "${PROMPT}"; then
echo "Typed smoke test completed."
else
echo "Typed smoke test did not finish within ${SMOKE_TIMEOUT}s; the daemon is still running."
echo "Retry after model warmup with: busctl --user call io.qvac.Jarvis /io/qvac/Jarvis io.qvac.Jarvis.Session Ask s \"${PROMPT}\""
fi
else
echo "Daemon is not available on the session D-Bus; saved setup and skipped live prompt."
echo "Inspect with: systemctl --user status jarvisd.service"
echo "Logs: journalctl --user -u jarvisd.service -n 80 --no-pager"
fi
if [[ "${DAEMON_BUS_READY}" == true ]]; then
echo "First-run setup complete; jarvisd is running."
else
echo "First-run setup saved. Start with: systemctl --user enable --now jarvisd.service"
fi
+24 -1
View File
@@ -185,8 +185,26 @@ activate_extension() {
}
sed "s#__JARVIS_BARE__#${BARE_BIN}#" "${ROOT_DIR}/packaging/jarvisd.service" > "${HOME}/.config/systemd/user/jarvisd.service"
log "wrote ${HOME}/.config/systemd/user/jarvisd.service"
DBUS_DIR="${XDG_DATA_HOME:-${HOME}/.local/share}/dbus-1/services"
mkdir -p "${DBUS_DIR}"
cat > "${DBUS_DIR}/io.qvac.Jarvis.service" <<'EOF'
[D-BUS Service]
Name=io.qvac.Jarvis
Exec=/bin/false
SystemdService=jarvisd.service
EOF
log "wrote ${DBUS_DIR}/io.qvac.Jarvis.service"
if [[ -f "${CONFIG_DIR}/config.json" ]]; then
log "kept existing ${CONFIG_DIR}/config.json"
else
cat > "${CONFIG_DIR}/config.json" <<'EOF'
{
"wakePhrase": "hey jarvis",
"modelProfile": "laptop-16gb",
"ttsEnabled": true
}
EOF
log "created ${CONFIG_DIR}/config.json with default wake phrase, laptop-16gb profile, and TTS enabled"
fi
if [[ ! -f "${CONFIG_DIR}/qvac.config.json" ]]; then
sed "s#__JARVIS_CACHE__#${CACHE_DIR}#g" "${ROOT_DIR}/packaging/qvac.config.template.json" > "${CONFIG_DIR}/qvac.config.json"
@@ -218,4 +236,9 @@ else
fi
log "install complete; log file ${LOG_FILE}"
echo "Installed JARVIS-QVAC to ${APP_DIR}"
echo "Run: ${APP_DIR}/packaging/first-run.sh"
if [[ "${1:-}" == "--enable" ]] || [[ "${PREVIOUSLY_ACTIVE}" == true ]]; then
echo "Jarvis is ready. Default wake phrase: hey jarvis. Change it in Settings."
else
echo "Start with: systemctl --user enable --now jarvisd.service"
echo "Then enable the GNOME extension: gnome-extensions enable [email protected]"
fi
+5 -3
View File
@@ -1,7 +1,8 @@
[Unit]
Description=JARVIS-QVAC local voice assistant
After=pipewire.service wireplumber.service graphical-session.target
Wants=pipewire.service
After=graphical-session.target pipewire.service pipewire-pulse.service wireplumber.service
Wants=pipewire.service wireplumber.service
PartOf=graphical-session.target
[Service]
Type=simple
@@ -10,8 +11,9 @@ ExecStart=__JARVIS_BARE__ %h/.local/share/jarvis-qvac/daemon/bare-entry.js
Environment=QVAC_CONFIG_PATH=%h/.config/jarvis/qvac.config.json
Environment=JARVIS_GPU_REQUIRED=1
Environment=JARVIS_QVAC_OWNER=jarvisd
Restart=on-failure
Restart=always
RestartSec=2
[Install]
WantedBy=default.target
WantedBy=graphical-session.target
+1
View File
@@ -7,6 +7,7 @@ fi
gnome-extensions disable [email protected] 2>/dev/null || true
systemctl --user disable --now jarvisd.service 2>/dev/null || true
rm -f "${HOME}/.config/systemd/user/jarvisd.service"
rm -f "${XDG_DATA_HOME:-${HOME}/.local/share}/dbus-1/services/io.qvac.Jarvis.service"
rm -rf "${HOME}/.local/share/jarvis-qvac" "${HOME}/.local/share/gnome-shell/extensions/[email protected]"
systemctl --user daemon-reload 2>/dev/null || true
if [[ "${2:-}" == "--purge-data" ]]; then
+3 -2
View File
@@ -63,7 +63,8 @@ if [[ -n "${BUNDLE_NAME}" ]]; then
test -n "${SOURCE_DIR}" || { echo "Bare bundle contained no application directory" >&2; exit 1; }
test -x "${SOURCE_DIR}/packaging/install.sh" || { echo "Bare bundle contained no installer" >&2; exit 1; }
echo "Installing the bundled Bare runtime; Node.js is not required"
exec bash "${SOURCE_DIR}/packaging/install.sh" "$@"
bash "${SOURCE_DIR}/packaging/install.sh" "$@"
exit $?
fi
fi
if ! command -v npm >/dev/null || ! command -v node >/dev/null; then
@@ -82,4 +83,4 @@ test -n "$SOURCE_DIR" || { echo "Downloaded archive contained no source director
test -x "$SOURCE_DIR/packaging/install.sh" || { echo "Downloaded ref has no installer" >&2; exit 1; }
echo "Installing into the current user account"
exec bash "$SOURCE_DIR/packaging/install.sh" "$@"
bash "$SOURCE_DIR/packaging/install.sh" "$@"
-6
View File
@@ -1,6 +0,0 @@
import fs from 'node:fs';
const [file, phrase, profile, tts] = Bare.argv.slice(2);
let config = {};
try { config = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
Object.assign(config, { wakePhrase: phrase, modelProfile: profile, ttsEnabled: tts === 'true' });
fs.writeFileSync(file, JSON.stringify(config, null, 2) + '\n');
+8
View File
@@ -0,0 +1,8 @@
import { SETTINGS_FIELDS, voiceSettings } from '../daemon/voice-settings.js';
import { ttsConfiguration } from '../daemon/tts-config.js';
import { QvacVoiceAdapter } from '../daemon/voice-adapters.js';
const settings = voiceSettings({ voiceId: 'M2', ttsSpeed: 1.2 });
const adapter = new QvacVoiceAdapter({ role: 'tts', settings });
if (adapter.ttsConfig.config.voice !== 'M2') throw new Error('Voice setting was not propagated');
if (ttsConfiguration(settings).config.ttsSpeed !== 1.2) throw new Error('Speech speed was not propagated');
console.log(`Settings runtime smoke passed: ${SETTINGS_FIELDS.length} fields, voice ${adapter.ttsConfig.config.voice}`);
+25
View File
@@ -0,0 +1,25 @@
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import { SettingsEditor } from '../apps/gnome-extension/[email protected]/settings-editor.js';
const configDir = GLib.get_user_config_dir();
if (!configDir.startsWith('/tmp/jarvis-settings-')) throw new Error('Use an isolated /tmp/jarvis-settings-* XDG_CONFIG_HOME for this check');
const root = Gio.File.new_for_uri(import.meta.url).get_parent().get_parent().get_path();
const directory = `${root}/apps/gnome-extension/[email protected]`;
GLib.mkdir_with_parents(`${configDir}/jarvis`, 0o700);
const file = Gio.File.new_for_path(`${configDir}/jarvis/config.json`);
const write = text => file.replace_contents(text, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null);
const read = () => new TextDecoder().decode(file.load_contents(null)[1]);
write(JSON.stringify({ ttsEnabled: false, unrelated: 'original' }));
const editor = new SettingsEditor(directory);
if (editor.values.ttsEnabled !== false) throw new Error('Existing settings were lost');
editor.setValue(editor.fields.find(f => f.key === 'voiceId'), 'M5');
write(JSON.stringify({ ttsEnabled: false, unrelated: 'updated elsewhere', ttsSpeed: 1.25 }));
editor.save();
const saved = JSON.parse(read());
if (saved.voiceId !== 'M5' || saved.ttsSpeed !== 1.25 || saved.unrelated !== 'updated elsewhere' || saved.ttsEnabled !== false) throw new Error('Settings merge failed');
write('{ invalid JSON');
let refused = false;
try { editor.save(); } catch { refused = true; }
if (!refused || read() !== '{ invalid JSON') throw new Error('Invalid JSON was overwritten');
file.delete(null);
print('Settings store smoke passed: existing values, concurrent merge, malformed-file protection');
+35
View File
@@ -0,0 +1,35 @@
import Adw from 'gi://Adw';
import Gtk from 'gi://Gtk';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import { fillSettingsWindow } from '../apps/gnome-extension/[email protected]/settings-window.js';
const root = Gio.File.new_for_uri(import.meta.url).get_parent().get_parent().get_path();
const directory = `${root}/apps/gnome-extension/[email protected]`;
const app = new Adw.Application({ application_id: 'io.qvac.Jarvis.SettingsSmoke', flags: Gio.ApplicationFlags.NON_UNIQUE });
app.connect('activate', () => {
const source = Gio.SettingsSchemaSource.new_from_directory(`${directory}/schemas`, Gio.SettingsSchemaSource.get_default(), false);
const settings = new Gio.Settings({ settings_schema: source.lookup('org.gnome.shell.extensions.jarvis', true) });
const window = new Adw.PreferencesWindow({ application: app });
const { editor, pages } = fillSettingsWindow(window, settings, directory);
const snapshots = ['voice', 'listening', 'desktop', 'models'];
let index = 0;
window.present();
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 700, () => {
try {
const paintable = new Gtk.WidgetPaintable({ widget: window });
const snapshot = new Gtk.Snapshot();
paintable.snapshot(snapshot, window.get_width(), window.get_height());
const texture = window.get_renderer().render_texture(snapshot.to_node(), null);
texture.save_to_png(`/tmp/jarvis-settings-${snapshots[index]}.png`);
if (++index === pages.length) {
const field = editor.fields.find(f => f.key === 'ttsPreset');
editor.rows.get(field.key)._setValue('chatterbox');
if (!editor.rows.get('ttsReferenceAudio').visible || editor.rows.get('voiceId').visible) throw new Error('Model-dependent controls did not update');
print(`Settings UI smoke passed: ${editor.rows.size} controls across ${pages.length} pages`);
app.quit(); return GLib.SOURCE_REMOVE;
}
window.set_visible_page(pages[index]); return GLib.SOURCE_CONTINUE;
} catch (error) { printerr(error.stack); app.quit(); throw error; }
});
});
app.run([]);
+21 -4
View File
@@ -1,4 +1,4 @@
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { readdir, readFile, writeFile, realpath, lstat } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SKILLS } from './catalog.js';
@@ -6,6 +6,23 @@ import { qvacStatus } from '../daemon/qvac-master.js';
const PERMISSIONS = Object.freeze({ read: 'read', write: 'write', dangerous: 'dangerous', computerUse: 'computer-use' });
// Resolve symlinks as well as lexical paths before accessing workspace files.
async function workspacePath(cwd, file, { write = false } = {}) {
const root = await realpath(path.resolve(cwd));
const target = path.resolve(cwd, file);
let resolved;
try { resolved = await realpath(target); }
catch (error) {
if (!write || error.code !== 'ENOENT') throw error;
const entry = await lstat(target).catch((cause) => { if (cause.code !== 'ENOENT') throw cause; return null; });
if (entry?.isSymbolicLink()) throw new Error('path is outside the Jarvis workspace or is a dangling symlink');
resolved = path.join(await realpath(path.dirname(target)), path.basename(target));
}
const relative = path.relative(root, resolved);
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error('path is outside the Jarvis workspace');
return resolved;
}
async function desktopApps() {
const dirs = ['/usr/share/applications', path.join(os.homedir(), '.local/share/applications')];
const apps = [];
@@ -54,19 +71,19 @@ export function createPhase2Tools({ cwd = process.cwd(), computer } = {}) {
name: 'fs_search', permission: PERMISSIONS.read,
description: 'Search local file and directory names below the Jarvis workspace.',
parameters: { type: 'object', properties: { query: { type: 'string' }, root: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] },
execute: ({ query, root = cwd, limit = 50 }) => searchFiles(root, query, Math.min(100, Number(limit) || 50)),
execute: async ({ query, root = cwd, limit = 50 }) => searchFiles(await workspacePath(cwd, root), query, Math.min(100, Number(limit) || 50)),
},
{
name: 'fs_read', permission: PERMISSIONS.read,
description: 'Read a UTF-8 local text file below the Jarvis workspace, capped at 400 KiB.',
parameters: { type: 'object', properties: { file: { type: 'string' } }, required: ['file'] },
execute: async ({ file }) => { const target = path.resolve(cwd, file); if (!target.startsWith(`${path.resolve(cwd)}${path.sep}`)) throw new Error('path is outside the Jarvis workspace'); return (await readFile(target, 'utf8')).slice(0, 400 * 1024); },
execute: async ({ file }) => { const target = await workspacePath(cwd, file); return (await readFile(target, 'utf8')).slice(0, 400 * 1024); },
},
{
name: 'fs_write', permission: PERMISSIONS.write,
description: 'Write a local text file only after an explicit confirmation flag is supplied.',
parameters: { type: 'object', properties: { file: { type: 'string' }, contents: { type: 'string' }, confirmed: { type: 'boolean' } }, required: ['file', 'contents', 'confirmed'] },
execute: async ({ file, contents, confirmed }) => { if (confirmed !== true) return { confirmation_required: true, action: 'write', file }; const target = path.resolve(cwd, file); if (!target.startsWith(`${path.resolve(cwd)}${path.sep}`)) throw new Error('path is outside the Jarvis workspace'); await writeFile(target, String(contents), 'utf8'); return { ok: true, file: target, bytes: String(contents).length }; },
execute: async ({ file, contents, confirmed }) => { if (confirmed !== true) return { confirmation_required: true, action: 'write', file }; const target = await workspacePath(cwd, file, { write: true }); await writeFile(target, String(contents), 'utf8'); return { ok: true, file: target, bytes: Buffer.byteLength(String(contents)) }; },
},
{
name: 'memory_recall', permission: PERMISSIONS.read,
+17
View File
@@ -60,3 +60,20 @@ test('computer audit stores hashes and never stores screenshot bytes', async ()
const row = await audit.record('observe', { name: 'Window', role: 'window' }, { ok: true, screenshotPath: frame }); const text = await readFile(path.join(dir, `cu-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}.jsonl`), 'utf8');
assert.equal(row.screenshot_hash.length, 64); assert.doesNotMatch(text, /private pixels/); assert.doesNotMatch(text, /frame\.png/);
});
test('event socket bounds UTF-8 input, handles async rejection, and closes active clients', async (t) => {
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-ipc-errors-'));
let ipc;
try { ipc = await createEventSocket({ socketPath: path.join(dir, 'events.sock'), onMessage: async () => { throw new Error('handler failed'); } }); }
catch (error) { if (error.code === 'EPERM') { t.skip('sandbox forbids binding Unix sockets'); return; } throw error; }
const connect = () => new Promise((resolve, reject) => { const client = net.createConnection(ipc.socketPath, () => resolve(client)); client.on('error', reject); });
try {
const client = await connect();
const response = new Promise(resolve => client.once('data', resolve));
client.write('{}\n'); assert.match(String(await response), /invalid IPC message/);
const closed = new Promise(resolve => client.once('close', resolve));
client.write('é'.repeat(40 * 1024)); await closed;
const idle = await connect(); const idleClosed = new Promise(resolve => idle.once('close', resolve));
await ipc.close(); await idleClosed;
} finally { if (ipc.server.listening) await ipc.close(); }
});
+25
View File
@@ -1,3 +1,7 @@
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
process.env.XDG_STATE_HOME = mkdtempSync(path.join(tmpdir(), 'jarvis-daemon-test-'));
import test from 'node:test';
import assert from 'node:assert/strict';
import { VoiceStateMachine } from '../daemon/voice-state.js';
@@ -145,3 +149,24 @@ test('QVAC scheduler prioritizes voice and keeps one active job', async () => {
assert.deepEqual(order, ['first', 'voice', 'media']);
assert.deepEqual(scheduler.status(), { running: 0, queued: [] });
});
test('shutdown still closes the harness when recovery or voice cleanup fails', async () => {
const daemon = new JarvisDaemon(); let closed = false;
daemon.recovery.save = () => { throw new Error('disk full'); };
daemon.harness = { cancel() {}, close: async () => { closed = true; } };
daemon.voiceLoop = { stop: async () => { throw new Error('voice cleanup'); } };
await assert.rejects(daemon.close(), /voice cleanup/);
assert.equal(closed, true);
});
test('cancel suppresses a late reply and revokes portal input', async () => {
const daemon = new JarvisDaemon(); let resolve; let revoked = false;
daemon.input = { revoke: () => { revoked = true; } };
daemon.harness = { ask: () => new Promise(r => { resolve = r; }), cancel() {}, close: async () => {} };
const replies = []; daemon.on('Reply', text => replies.push(text));
try {
const ask = daemon.ask('hello'); await Promise.resolve(); daemon.cancel();
resolve({ text: 'late answer' }); assert.equal(await ask, '');
assert.deepEqual(replies, []); assert.equal(daemon.state, 'ARMED'); assert.equal(revoked, true);
} finally { await daemon.close(); }
});
+24 -18
View File
@@ -211,12 +211,18 @@ test('GNOME GI imports expose default namespaces and panel has a real menu', ()
assert.match(extensionSource, /import Shell from 'gi:\/\/Shell'/);
assert.match(extensionSource, /import Meta from 'gi:\/\/Meta'/);
assert.match(extensionSource, /new PanelMenu.Button\(0.0, 'Jarvis QVAC', false\)/);
assert.match(extensionSource, /PushToTalk/);
assert.doesNotMatch(extensionSource, /DO_NOT_AUTO_START/);
assert.match(extensionSource, /DBusProxyFlags\.NONE/);
assert.match(extensionSource, /_keepSyncing/);
assert.match(extensionSource, /Jarvis is starting/);
assert.match(extensionSource, /\['Thinking'/);
assert.match(extensionSource, /\['ToolCall'/);
assert.match(extensionSource, /_openPopup/);
assert.match(uiSource, /Open conversation/);
assert.match(extensionSource, /PopupMenuSection/);
assert.match(extensionSource, /osd\.attach\(this\._panelBox\)/);
assert.match(uiSource, /jarvis-panel-state/);
assert.doesNotMatch(uiSource, /style_class: 'jarvis-osd'/);
assert.match(extensionSource, /_openSettings/);
assert.match(extensionSource, /PopupMenuItem\('Settings'\)/);
assert.match(extensionSource, /PopupMenuItem\('Grant desktop'\)/);
@@ -249,12 +255,14 @@ test('session panel stays closed while Jarvis speaks unless expanded', () => {
assert.match(session.view.transcript.children[0].text, /still talking/);
});
test('OSD shows listening without opening a conversation panel', () => {
const { JarvisOsd, SessionPanel, timers } = harness();
test('voice status lives in the panel chip, not a floating overlay', () => {
const { JarvisOsd, SessionPanel, timers, chrome } = harness();
const osd = new JarvisOsd();
const session = new SessionPanel();
osd.attach();
const bar = { children: [], add_child(child) { this.children.push(child); } };
osd.attach(bar);
session.attach();
assert.equal(bar.children[0], osd.root);
osd.setState('LISTENING');
assert.equal(osd.root.visible, true);
assert.match(osd.label.text, /Listening/);
@@ -263,11 +271,18 @@ test('OSD shows listening without opening a conversation panel', () => {
assert.equal(osd.root.visible, false);
osd.setState('SPEAKING');
assert.equal(osd.root.visible, true);
assert.ok(timers.size >= 1);
assert.match(osd.label.text, /Speaking/);
assert.equal(timers.size, 0);
osd.setState('THINKING');
assert.match(osd.label.text, /Thinking/);
osd.setState('SLEEPING');
assert.match(osd.label.text, /Privacy/);
osd.showWake();
assert.match(osd.label.text, /Wake/);
assert.equal(timers.size, 1);
osd.destroy();
assert.equal(timers.size, 0);
assert.equal(chrome.includes(osd.root), false);
});
test('computer-use chrome shows a target without a transcript', () => {
@@ -376,18 +391,9 @@ test('closing the popup ends hold-to-talk', () => {
assert.deepEqual(talks, [false]);
});
test('prefs bind every GSettings schema key', () => {
test('both settings entry points use the shared preferences window', () => {
const prefs = readFileSync(new URL('../apps/gnome-extension/[email protected]/prefs.js', import.meta.url), 'utf8');
const schema = readFileSync(new URL('../apps/gnome-extension/[email protected]/schemas/org.gnome.shell.extensions.jarvis.gschema.xml', import.meta.url), 'utf8');
const keys = [...schema.matchAll(/<key name="([^"]+)"/g)].map((match) => match[1]);
assert.ok(keys.length >= 14);
for (const key of keys) assert.match(prefs, new RegExp(`['"]${key}['"]`));
assert.match(prefs, /wakePhrase/);
assert.match(prefs, /ttsEnabled/);
assert.match(prefs, /modelProfile/);
assert.match(prefs, /'tray'/);
assert.match(prefs, /'expanded'/);
assert.match(prefs, /ComputerGrant/);
assert.match(prefs, /Allow now/);
assert.match(prefs, /ComputerRevoke/);
const control = readFileSync(new URL('../apps/control-center/main.js', import.meta.url), 'utf8');
assert.match(prefs, /fillSettingsWindow/);
assert.match(control, /fillSettingsWindow/);
});
+50 -7
View File
@@ -105,7 +105,12 @@ exit 0
assert.equal(await readFile(path.join(configDir, 'config.json'), 'utf8'), '{"wakePhrase":"hey jarvis","ttsEnabled":true}\n');
const unit = await readFile(path.join(home, '.config/systemd/user/jarvisd.service'), 'utf8');
assert.match(unit, /bare-runtime-linux/);
assert.match(unit, /WantedBy=default\.target/);
assert.match(unit, /Restart=always/);
assert.doesNotMatch(unit, /\/usr\/bin\/node/);
const dbusService = await readFile(path.join(home, '.local/share/dbus-1/services/io.qvac.Jarvis.service'), 'utf8');
assert.match(dbusService, /Name=io\.qvac\.Jarvis/);
assert.match(dbusService, /SystemdService=jarvisd\.service/);
const commands = await readFile(log, 'utf8');
assert.match(commands, /systemctl --user stop jarvisd\.service/);
assert.match(commands, /gnome-extensions enable jarvis@qvac\.local/);
@@ -128,11 +133,49 @@ exit 0
}
});
test('first-run keeps TTS enabled even when the preview is skipped', () => {
const source = readFileSync(new URL('../packaging/first-run.sh', import.meta.url), 'utf8');
assert.match(source, /Play a TTS preview now\?/);
assert.match(source, /TTS_ENABLED=true/);
assert.doesNotMatch(source, /Enable TTS preview\?/);
assert.doesNotMatch(source, /TTS_ENABLED=false/);
assert.match(source, /spoken replies remain enabled/);
test('install.sh seeds default config when missing and does not require first-run', async () => {
const work = await mkdtemp(path.join(os.tmpdir(), 'jarvis-install-defaults-'));
const home = path.join(work, 'home');
const root = path.join(work, 'src');
const bin = path.join(work, 'bin');
const machine = (await run('uname', ['-m'], process.env)).stdout.trim();
const bareName = /aarch64|arm64/.test(machine) ? 'bare-runtime-linux-arm64' : 'bare-runtime-linux-x64';
try {
await mkdir(path.join(root, 'packaging'), { recursive: true });
await mkdir(path.join(root, 'apps/gnome-extension'), { recursive: true });
await mkdir(path.join(root, 'node_modules', bareName, 'bin'), { recursive: true });
await mkdir(bin, { recursive: true });
await cp(path.join(repo, 'packaging/install.sh'), path.join(root, 'packaging/install.sh'));
await cp(path.join(repo, 'packaging/jarvisd.service'), path.join(root, 'packaging/jarvisd.service'));
await cp(path.join(repo, 'packaging/qvac.config.template.json'), path.join(root, 'packaging/qvac.config.template.json'));
await cp(path.join(repo, 'apps/gnome-extension/[email protected]'), path.join(root, 'apps/gnome-extension/[email protected]'), { recursive: true });
await writeFile(path.join(root, 'node_modules', bareName, 'bin', 'bare'), '#!/bin/sh\nexit 0\n');
await chmod(path.join(root, 'node_modules', bareName, 'bin', 'bare'), 0o755);
for (const name of ['systemctl', 'gnome-extensions', 'gsettings', 'gdbus', 'logger']) {
await writeFile(path.join(bin, name), `#!/bin/sh\nexit 0\n`);
await chmod(path.join(bin, name), 0o755);
}
const result = await run('bash', [path.join(root, 'packaging/install.sh'), '--enable'], {
...process.env,
HOME: home,
PATH: `${bin}:${process.env.PATH}`,
XDG_DATA_HOME: path.join(home, '.local/share'),
XDG_CACHE_HOME: path.join(home, '.cache'),
XDG_STATE_HOME: path.join(home, '.local/state'),
});
const config = JSON.parse(await readFile(path.join(home, '.config/jarvis/config.json'), 'utf8'));
assert.equal(config.wakePhrase, 'hey jarvis');
assert.equal(config.modelProfile, 'laptop-16gb');
assert.equal(config.ttsEnabled, true);
assert.match(result.stdout, /Jarvis is ready/);
assert.doesNotMatch(result.stdout, /first-run/);
const installSource = readFileSync(new URL('../packaging/install.sh', import.meta.url), 'utf8');
assert.doesNotMatch(installSource, /first-run/);
assert.equal(existsSync(path.join(repo, 'packaging/first-run.sh')), false);
assert.equal(existsSync(path.join(repo, 'packaging/write-config.js')), false);
} finally {
await rm(work, { recursive: true, force: true });
}
});
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { Agent, acquireQvac, releaseQvac, closeQvac, qvacStatus } from '../daemon/qvac-master.js';
test('concurrent master acquisition loads once and failed acquisition does not leak owners', async () => {
const original = { resources: Agent.engine.resources, load: Agent.engine.load, close: Agent.engine.close };
let loads = 0;
Agent.engine.resources = async () => ({ gpus: [{}] });
Agent.engine.load = async () => { loads++; return { device: 'gpu' }; };
Agent.engine.close = async () => {};
try {
await Promise.all([acquireQvac(), acquireQvac()]);
assert.equal(loads, 1); assert.equal(qvacStatus().owners, 2);
releaseQvac(); releaseQvac(); await closeQvac();
Agent.engine.resources = async () => { throw new Error('discovery failed'); };
await assert.rejects(acquireQvac(), /discovery failed/);
assert.equal(qvacStatus().owners, 0);
Agent.engine.resources = async () => ({ gpus: [{}] });
await acquireQvac(); assert.equal(loads, 2); releaseQvac();
} finally { await closeQvac(); Object.assign(Agent.engine, original); }
});
+107
View File
@@ -0,0 +1,107 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { mkdtemp, mkdir, writeFile, readFile, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
import { ComputerUseSession } from '../computer-use/session.js';
import { ComputerActuator } from '../computer-use/actuator.js';
import { PortalInputBackend } from '../computer-use/portal-input.js';
import { createComputerObserveTools } from '../skills/computer-observe.js';
import { createPhase2Tools } from '../skills/phase2-tools.js';
import { VoiceStateMachine } from '../daemon/voice-state.js';
import { assertLocalEndpoint } from '../daemon/network-policy.js';
const require = createRequire(import.meta.url);
const custom = require('../vendor/agent-harness/agent/custom-tools.js');
test('custom tool permissions survive registration and default to confirmation', () => {
const id = 'review-permissions';
try {
custom.register(id, ['read', 'write', 'dangerous', 'computer-use', undefined].map((permission, i) => ({ name: `review_${i}`, permission })));
assert.equal(custom.needsPermission(id, 'review_0', 'ask'), false);
for (let i = 1; i < 5; i++) {
assert.equal(custom.needsPermission(id, `review_${i}`, 'ask'), true);
assert.equal(custom.needsPermission(id, `review_${i}`, 'always-approve'), false);
}
custom.unregister(id, 'review_1');
assert.equal(custom.needsPermission(id, 'review_1', 'ask'), false);
} finally { custom.clear(id); }
});
test('workspace tools reject outside roots and symlink escapes and count UTF-8 bytes', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-path-test-'));
const root = path.join(dir, 'workspace'); await mkdir(root);
const outside = path.join(dir, 'outside'); await mkdir(outside);
await writeFile(path.join(outside, 'secret'), 'private');
await symlink(outside, path.join(root, 'link'));
await symlink(path.join(outside, 'missing'), path.join(root, 'dangling'));
const tools = Object.fromEntries(createPhase2Tools({ cwd: root }).map(t => [t.name, t]));
await assert.rejects(tools.fs_read.execute({ file: 'link/secret' }), /outside/);
await assert.rejects(tools.fs_write.execute({ file: 'link/new', contents: 'x', confirmed: true }), /outside/);
await assert.rejects(tools.fs_write.execute({ file: 'dangling', contents: 'x', confirmed: true }), /symlink/);
await assert.rejects(tools.fs_search.execute({ root: outside, query: 'secret' }), /outside/);
const result = await tools.fs_write.execute({ file: 'new', contents: 'é', confirmed: true });
assert.equal(result.bytes, 2);
assert.equal(await readFile(path.join(root, 'new'), 'utf8'), 'é');
});
test('coordinate click uses portal input even when accessibility actions exist', async () => {
const session = new ComputerUseSession(); session.grant(); const sent = [];
const actuator = new ComputerActuator({ session, input: { send: a => sent.push(a) }, atspiAction: () => assert.fail('unexpected semantic click'), sleep: async () => {} });
await actuator.click({ x: 20, y: 30 });
assert.equal(sent[0].x, 20);
});
test('stale semantic refs fail before input and revocation during preview prevents action', async () => {
const session = new ComputerUseSession(); session.grant();
const actuator = new ComputerActuator({ session, find: async () => [], input: { send: () => assert.fail('unexpected input') }, highlight: async () => session.revoke() });
await assert.rejects(actuator.type({ ref: 'missing', text: 'secret' }), /stale/);
await assert.rejects(actuator.click({ x: 1, y: 1 }), /inactive/);
});
test('expired grants prevent desktop observation', async () => {
let now = 0; const computer = new ComputerUseSession({ clock: () => now }); computer.grant(); now = 180001;
const [observe] = createComputerObserveTools({ computer, observer: { observe: () => assert.fail('expired observation') } });
await assert.rejects(observe.execute(), /grant/);
});
function helper() {
const child = new EventEmitter();
child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.stdin = new EventEmitter(); child.stdin.writable = true; child.stdin.write = () => {};
child.kill = () => { child.killed = true; };
return child;
}
test('portal grants wait for a complete readiness message and clear on exit', async () => {
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
const grant = input.grant();
child.stdout.emit('data', '{"type":"rea'); assert.equal(input.available, false);
child.stdout.emit('data', 'dy","restore_token_present":false}\n');
assert.equal((await grant).backend, 'portal-ei');
child.emit('close', 0); assert.equal(input.available, false);
assert.throws(() => input.send({}), /unavailable/);
});
test('revoked portal grants cannot become ready later', async () => {
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
const grant = input.grant(); input.revoke();
child.stdout.emit('data', '{"type":"ready"}\n');
await assert.rejects(grant, /revoked/); assert.equal(input.available, false); assert.equal(child.killed, true);
});
test('portal helper errors reject and allow a new grant', async () => {
const child = helper(); const input = new PortalInputBackend({ spawnImpl: () => child });
const grant = input.grant(); child.stdout.emit('data', '{"type":"error","reason":"denied"}\n');
await assert.rejects(grant, /denied/); assert.equal(input.process, null);
});
test('idle timer never attempts to sleep a thinking or speaking voice', () => {
let now = 0; const voice = new VoiceStateMachine({ now: () => now, idleMs: 10 });
voice.typedUtterance(); now = 100; assert.equal(voice.expireIdle(), 'THINKING');
voice.speak(); now = 200; assert.equal(voice.expireIdle(), 'SPEAKING');
});
test('local endpoint policy accepts IPv6 loopback', () => {
assert.equal(assertLocalEndpoint('http://[::1]:8080').hostname, '[::1]');
});
+175
View File
@@ -0,0 +1,175 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { SETTINGS_FIELDS, voiceSettings } from '../daemon/voice-settings.js';
import { normalizeSettings, mergeSettings, settingVisible } from '../apps/gnome-extension/[email protected]/settings-values.js';
import { TTS_PRESETS, ttsConfiguration, scaleSpeech } from '../daemon/tts-config.js';
import { QvacVoiceAdapter } from '../daemon/voice-adapters.js';
import { Agent } from '../daemon/qvac-master.js';
import { JarvisDaemon } from '../daemon/index.js';
import { ComputerUseSession } from '../computer-use/session.js';
import { PipeWireCapture } from '../daemon/audio-pipewire.js';
import { PipeWirePlayback } from '../daemon/audio-playback.js';
import { VadSegmenter } from '../daemon/vad.js';
import { VoiceLoop } from '../daemon/voice-loop.js';
import { ttsLoadConfigSchema } from '../node_modules/@qvac/inference/dist/schemas/text-to-speech.js';
import * as registry from '../node_modules/@qvac/inference/dist/models/registry/models.js';
test('settings migrate aliases, retain explicit disables, and reject invalid numeric input', () => {
const settings = voiceSettings({ tts_enabled: false, voice_id: 'M3', language: 'es-MX', computer_step_budget: '50', tts_speed: 1.4 });
assert.equal(settings.ttsEnabled, false); assert.equal(settings.voiceId, 'M3'); assert.equal(settings.asrLanguage, 'es');
assert.equal(settings.computerSteps, 50); assert.equal(settings.ttsSpeed, 1.4);
assert.equal(voiceSettings({ ttsSpeed: 100 }).ttsSpeed, 1.05);
assert.throws(() => voiceSettings({ ttsSpeed: 100 }, { strict: true }), /Speaking speed/);
assert.throws(() => voiceSettings({ computerSteps: '' }, { strict: true }), /Actions per grant/);
assert.equal(voiceSettings({ ttsEnabled: true, tts_enabled: false }).ttsEnabled, true);
});
test('saving one window preserves unrelated changes from another and removes conflicting aliases', () => {
const merged = mergeSettings({ tts_enabled: false, ttsSpeed: 1.5, unrelated: 'kept' }, { ttsEnabled: true }, SETTINGS_FIELDS);
assert.deepEqual(merged, { ttsSpeed: 1.5, unrelated: 'kept', ttsEnabled: true });
assert.equal(normalizeSettings(merged, SETTINGS_FIELDS).ttsSpeed, 1.5);
});
test('every speech preset uses a real registry asset and passes the installed SDK schema', () => {
for (const ttsPreset of Object.keys(TTS_PRESETS)) {
const settings = voiceSettings({ ttsPreset, ttsLanguage: 'fr', voiceId: 'M4', ttsSpeed: 1.25, ttsDescription: 'A warm voice speaks softly.' });
const config = ttsConfiguration(settings);
assert.ok(registry[config.model], config.model);
assert.equal(ttsLoadConfigSchema.safeParse(config.config).success, true, ttsPreset);
if (ttsPreset === 'supertonic-en') assert.equal(config.config.language, 'en');
if (ttsPreset === 'supertonic3') assert.equal(config.config.language, 'fr');
if (ttsPreset === 'parler') { assert.equal(config.config.description, settings.ttsDescription); assert.equal(config.config.voice, undefined); }
}
for (const choice of SETTINGS_FIELDS.find(f => f.key === 'asrModel').options) assert.ok(registry[choice.value], choice.value);
});
test('irrelevant voice controls hide when choosing a different engine', () => {
const field = key => SETTINGS_FIELDS.find(f => f.key === key);
assert.equal(settingVisible(field('ttsReferenceAudio'), { ttsPreset: 'chatterbox' }), true);
assert.equal(settingVisible(field('voiceId'), { ttsPreset: 'chatterbox' }), false);
assert.equal(settingVisible(field('ttsDescription'), { ttsPreset: 'parler' }), true);
});
test('reply volume scales PCM without modifying the original', () => {
const original = Int16Array.from([32767, -32768, 1000]);
assert.deepEqual([...scaleSpeech(original, 50)], [16384, -16384, 500]);
assert.deepEqual([...scaleSpeech(original, 0)], [0, 0, 0]);
assert.equal(original[0], 32767);
});
test('voice adapter sends selected config and uses each model native sample rate', async () => {
const original = Agent.engine.ensureInit; const loads = [];
Agent.engine.ensureInit = async () => ({
TTS_S3GEN_EN_CHATTERBOX: registry.TTS_S3GEN_EN_CHATTERBOX,
loadModel: async options => { loads.push(options); return 'test-model'; },
unloadModel: async () => {},
textToSpeech: async () => ({ buffer: Int16Array.from([1000, -1000]) }),
});
try {
for (const ttsPreset of ['supertonic3', 'chatterbox']) {
const adapter = new QvacVoiceAdapter({ role: 'tts', settings: voiceSettings({ ttsPreset, voiceId: 'M2', ttsSpeed: 1.3, ttsLanguage: 'fr', ttsVolume: 50, ttsReferenceAudio: '/tmp/reference.wav' }) });
try {
await adapter.start();
const audio = await adapter.speak('Bonjour.');
assert.equal(audio.sampleRate, ttsPreset === 'chatterbox' ? 24000 : 44100);
assert.deepEqual([...audio.samples], [500, -500]);
} finally { await adapter.stop(); }
}
assert.equal(loads[0].modelConfig.voice, 'M2'); assert.equal(loads[0].modelConfig.ttsSpeed, 1.3);
assert.equal(loads[1].modelConfig.referenceAudioSrc, '/tmp/reference.wav');
assert.deepEqual(loads[1].modelConfig.s3genModelSrc, registry.TTS_S3GEN_EN_CHATTERBOX);
} finally { Agent.engine.ensureInit = original; }
});
test('desktop mode and configured duration are enforced at the session boundary', () => {
let now = 0;
const session = new ComputerUseSession({ mode: 'observe', grantMinutes: 1, clock: () => now });
session.grant(); assert.throws(() => session.beginStep(), /observe-only/);
now = 60001; assert.equal(session.status().active, false);
session.mode = 'off'; assert.throws(() => session.grant(), /disabled/);
});
function child() {
const result = new EventEmitter(); result.stdout = new EventEmitter(); result.stderr = new EventEmitter(); result.stdin = new EventEmitter();
result.kill = () => {}; result.stdin.end = () => queueMicrotask(() => result.emit('close', 0)); return result;
}
test('PipeWire routes to the selected devices and uses the synthesis sample rate', async () => {
let captureArgs; let playbackArgs;
const capture = new PipeWireCapture({ target: 'my-mic', spawnImpl: (_command, args) => { captureArgs = args; return child(); } });
capture.start(); capture.stop(); assert.equal(captureArgs[captureArgs.indexOf('--target') + 1], 'my-mic');
const playback = new PipeWirePlayback({ target: 'my-speakers', spawnImpl: (_command, args) => { playbackArgs = args; return child(); } });
await playback.play(new Int16Array(100), 24000);
assert.equal(playbackArgs[playbackArgs.indexOf('--rate') + 1], '24000');
assert.equal(playbackArgs[playbackArgs.indexOf('--target') + 1], 'my-speakers');
});
test('Hold Talk only mode ignores wake and automatic listening', () => {
const capture = new EventEmitter(); let wakeFrames = 0; let vadFrames = 0;
const wake = new EventEmitter(); wake.push = () => wakeFrames++;
const vad = new EventEmitter(); vad.push = () => vadFrames++;
const loop = new VoiceLoop({ daemon: { state: 'LISTENING', emit() {} }, capture, wake, vad, listeningMode: 'ptt' });
loop.running = true; loop.pushAudio(Buffer.alloc(10)); assert.equal(wakeFrames, 0); assert.equal(vadFrames, 0);
loop.ptt = true; loop.pushAudio(Buffer.alloc(10)); assert.equal(vadFrames, 1);
});
test('VAD discards short noises and bounds the whole recording including pauses', () => {
const vad = new VadSegmenter({ params: { minSpeechDurationMs: 300, minSilenceDurationMs: 500, maxSpeechDurationMs: 600 } });
const speech = Buffer.alloc(3200); for (let i = 0; i < speech.length; i += 2) speech.writeInt16LE(20000, i);
const silence = Buffer.alloc(3200); const utterances = []; vad.on('utterance', audio => utterances.push(audio));
vad.push(speech); for (let i = 0; i < 5; i++) vad.push(silence);
assert.equal(utterances.length, 0); assert.equal(vad.speaking, false);
for (let i = 0; i < 3; i++) { vad.push(speech); vad.push(silence); }
assert.equal(utterances.length, 1); assert.equal(utterances[0].length, 19200);
});
test('Apply reloads voice and desktop settings and keeps restart requirements until restart', async () => {
const previous = process.env.XDG_CONFIG_HOME;
const dir = await mkdtemp(path.join(tmpdir(), 'jarvis-settings-'));
process.env.XDG_CONFIG_HOME = dir; await mkdir(path.join(dir, 'jarvis'));
const daemon = new JarvisDaemon(); let stopped = 0;
daemon.setState = state => { daemon.state = state; };
daemon.input = { revoke() {} }; daemon.computer.audit = null;
daemon.startVoice = async () => { daemon.voiceLoop = { stop: async () => stopped++, status: { tts: true, errors: {} } }; };
daemon.voiceLoop = { stop: async () => stopped++, status: {} };
try {
await writeFile(path.join(dir, 'jarvis/config.json'), JSON.stringify({ voiceId: 'M5', computerMode: 'observe', computerSteps: 7, modelProfile: 'desktop-gpu' }));
const result = JSON.parse(await daemon.reloadSettings());
assert.equal(daemon.settings.voiceId, 'M5'); assert.equal(daemon.computer.mode, 'observe'); assert.equal(daemon.computer.stepsMax, 7);
assert.ok(result.restartRequired.includes('modelProfile')); assert.equal(stopped, 1);
assert.ok(JSON.parse(await daemon.reloadSettings()).restartRequired.includes('modelProfile'));
daemon._activeAsk = Promise.resolve(); await assert.rejects(daemon.reloadSettings(), /current request/);
} finally {
clearInterval(daemon._idleTimer); clearInterval(daemon._telemetryTimer);
if (previous === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = previous;
await rm(dir, { recursive: true, force: true });
}
});
test('voice preview uses selected text without changing chat history or the repeat reply', async () => {
const daemon = new JarvisDaemon(); const speech = []; const replies = [];
daemon.setState = state => { daemon.state = state; };
daemon.lastReply = 'Original conversation reply';
daemon.on('Reply', text => replies.push(text));
daemon.voiceLoop = { status: { tts: true }, speak: async text => speech.push(text) };
try {
await daemon.previewVoice('A sample of my chosen voice.');
assert.deepEqual(speech, ['A sample of my chosen voice.']);
assert.deepEqual(replies, []); assert.equal(daemon.lastReply, 'Original conversation reply');
assert.equal(daemon.state, 'LISTENING');
daemon.settings = { ...daemon.settings, listeningMode: 'single' };
await daemon.previewVoice('Another sample.'); assert.equal(daemon.state, 'ARMED');
daemon.locked = true; await assert.rejects(daemon.previewVoice('Locked sample'), /Unlock/);
} finally { clearInterval(daemon._idleTimer); clearInterval(daemon._telemetryTimer); }
});
test('a disabled microphone never starts capture while speech output remains available', async () => {
const capture = new EventEmitter(); capture.start = () => assert.fail('microphone should be disabled');
const loop = new VoiceLoop({ capture, asr: null, tts: { start: async () => {} } });
await loop.start();
assert.equal(loop.status.capture, false); assert.equal(loop.status.asr, false); assert.equal(loop.status.tts, true);
});
+51
View File
@@ -151,6 +151,57 @@ test('QVAC numeric PCM arrays preserve signed 16-bit samples', () => {
assert.deepEqual([...pcmS16le([256, -32768, 32767]).samples], [256, -32768, 32767]);
});
test('voice loop retries microphone capture after PipeWire comes up', async () => {
let starts = 0;
const capture = new EventEmitter();
capture.start = () => {
starts += 1;
if (starts === 1) queueMicrotask(() => capture.emit('close', { code: 1 }));
};
capture.stop = () => {};
const loop = new VoiceLoop({
capture,
asr: { start: async () => {} },
tts: { start: async () => {} },
wake: new WakeEngine({ detect: () => null }),
vad: new VadSegmenter(),
captureRetryMs: 20,
});
loop.on('error', () => {});
await loop.start();
assert.equal(loop.status.asr, true);
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(loop.status.capture, false);
await new Promise((resolve) => setTimeout(resolve, 40));
assert.ok(starts >= 2);
assert.equal(loop.status.capture, true);
await loop.stop();
});
test('voice loop retries ASR after a first-login GPU miss', async () => {
let attempts = 0;
const capture = new EventEmitter();
capture.start = () => { capture.started = true; };
capture.stop = () => {};
const loop = new VoiceLoop({
capture,
asr: { start: async () => { attempts += 1; if (attempts === 1) throw new Error('GPU not ready'); } },
tts: { start: async () => {} },
wake: new WakeEngine({ detect: () => null }),
vad: new VadSegmenter(),
asrRetryMs: 20,
});
loop.on('error', () => {});
await loop.start();
assert.equal(loop.status.asr, false);
assert.equal(capture.started, undefined);
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(attempts, 2);
assert.equal(loop.status.asr, true);
assert.equal(capture.started, true);
await loop.stop();
});
test('voice adapters load canonical ASR and TTS plugins', () => {
const source = readFileSync(new URL('../daemon/voice-adapters.js', import.meta.url), 'utf8');
assert.match(source, /whispercpp-transcription/);
+11
View File
@@ -12,6 +12,7 @@ const MAX_DESC = 2000;
const bySession = new Map();
const sessionOpts = new Map();
const handlers = new Map();
const permissions = new Map();
function setReserved(names) {
reserved = new Set(names);
@@ -83,6 +84,9 @@ function register(sessionId, tools) {
throw new Error('too many custom tools (max ' + MAX_TOOLS + ')');
}
map.set(schema.name, schema);
let gates = permissions.get(sessionId);
if (!gates) permissions.set(sessionId, gates = new Map());
gates.set(schema.name, t.permission || 'write');
if (typeof t.execute === 'function') {
let h = handlers.get(sessionId);
if (!h) {
@@ -101,6 +105,7 @@ function unregister(sessionId, name) {
if (map && name) map.delete(name);
const h = handlers.get(sessionId);
if (h && name) h.delete(name);
permissions.get(sessionId)?.delete(name);
return list(sessionId);
}
@@ -108,6 +113,7 @@ function clear(sessionId) {
bySession.delete(sessionId);
sessionOpts.delete(sessionId);
handlers.delete(sessionId);
permissions.delete(sessionId);
}
function has(sessionId, name) {
@@ -120,11 +126,16 @@ function getHandler(sessionId, name) {
return h && h.get(name);
}
function needsPermission(sessionId, name, mode) {
return mode !== 'always-approve' && has(sessionId, name) && permissions.get(sessionId)?.get(name) !== 'read';
}
function defs(sessionId) {
return list(sessionId);
}
module.exports = {
needsPermission,
NAME_RE,
MAX_TOOLS,
setReserved,
+1 -1
View File
@@ -781,7 +781,7 @@ async function runTurn(ctx) {
continue;
}
if (sandbox.needsPermission(name, mode) && !planMode.isPlanFilePath(args.path, tracker.planPath)) {
if (customTools.needsPermission(session.id, name, mode) || (sandbox.needsPermission(name, mode) && !((name === 'write_file' || name === 'search_replace') && planMode.isPlanFilePath(args.path, tracker.planPath)))) {
let remembered = null;
try {
remembered = permStore.resolve(name, args);