@@ -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
|
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
|
Omit `--enable` to install files without starting the service. With
|
||||||
setup afterward:
|
`--enable`, the installer writes default settings if needed, starts
|
||||||
|
`jarvisd`, and enables the GNOME extension. The default wake phrase is
|
||||||
~~~bash
|
**hey jarvis**; change it in Settings. Spoken replies are on.
|
||||||
bash ~/.local/share/jarvis-qvac/packaging/first-run.sh
|
|
||||||
gnome-extensions enable jarvis@qvac.local
|
|
||||||
|
|
||||||
If GNOME says the extension does not exist immediately after installation, log
|
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
|
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.
|
at session startup; the installer persists the enabled state for that login.
|
||||||
~~~
|
|
||||||
|
|
||||||
The rolling installer first downloads a self-contained architecture bundle
|
The rolling installer first downloads a self-contained architecture bundle
|
||||||
with the Bare ELF runtime and all application dependencies. It therefore needs
|
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
|
||||||
bash packaging/install.sh --enable
|
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
|
The installer writes `~/.config/jarvis/config.json` when it is missing, with
|
||||||
model profile, optionally previews TTS, and sends a typed smoke-test prompt
|
wake phrase `hey jarvis`, model profile `laptop-16gb`, and TTS enabled. Change
|
||||||
through the session bus.
|
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:
|
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:doctor # QVAC installation diagnostics
|
||||||
npm run qvac:status # Single-master runtime status
|
npm run qvac:status # Single-master runtime status
|
||||||
npm run package:test # Build and inspect release artifacts
|
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
|
npm run start # Start jarvisd in the current session
|
||||||
~~~
|
~~~
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
# Jarvis Control Center
|
# Jarvis settings
|
||||||
|
|
||||||
This package is the GTK4/libadwaita control-plane boundary. Its pages are
|
Run `python3 apps/control-center/main.py` (or `gjs -m apps/control-center/main.js`).
|
||||||
General, Voice, Models, Memory, Skills, Computer use, Privacy, Lab, and About.
|
The Control Center and the GNOME extension's Settings button now open the same
|
||||||
The daemon remains the owner of QVAC, audio, portals, and the harness. The
|
Libadwaita settings pages and use the same configuration catalog. Requires GJS,
|
||||||
implemented `main.py` application uses libadwaita NavigationSplitView, stores
|
GTK 4, and Libadwaita 1.4 or newer.
|
||||||
preferences under `~/.config/jarvis/config.json`, and communicates with the
|
|
||||||
daemon over session D-Bus. Start it with `npm run --workspace
|
Voice, listening, and desktop settings are saved to
|
||||||
@jarvis-qvac/control-center start`.
|
`$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.
|
||||||
|
|||||||
@@ -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
@@ -1,115 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import json
|
"""Compatibility entry point for the shared Jarvis preferences window."""
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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 main():
|
||||||
def __init__(self):
|
os.execvp('gjs', ['gjs', '-m', str(Path(__file__).with_name('main.js'))])
|
||||||
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')
|
|
||||||
|
|
||||||
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):
|
if __name__ == '__main__':
|
||||||
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')]))
|
main()
|
||||||
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()
|
|
||||||
|
|||||||
@@ -18,14 +18,23 @@ const GLYPHS = { ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎
|
|||||||
|
|
||||||
class JarvisProxy {
|
class JarvisProxy {
|
||||||
async connect() {
|
async connect() {
|
||||||
const proxy = await new Promise((resolve, reject) => Gio.DBusProxy.new(
|
if (this._closed) return this;
|
||||||
Gio.DBus.session, Gio.DBusProxyFlags.DO_NOT_AUTO_START, null, BUS, PATH, IFACE, null,
|
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); } },
|
(source, result) => { try { resolve(Gio.DBusProxy.new_finish(result)); } catch (error) { reject(error); } },
|
||||||
));
|
));
|
||||||
if (this._closed) { proxy.run_dispose(); return this; }
|
try {
|
||||||
this.proxy = proxy;
|
const proxy = await this._connecting;
|
||||||
this._ownerSignal = proxy.connect('notify::g-name-owner', () => this.onOwnerChanged?.(this.owned()));
|
if (this._closed) { proxy.run_dispose(); return this; }
|
||||||
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; } }
|
owned() { try { return Boolean(this.proxy?.g_name_owner || this.proxy?.get_name_owner?.()); } catch { return false; } }
|
||||||
call(name, signature = null, value = null) {
|
call(name, signature = null, value = null) {
|
||||||
@@ -47,7 +56,7 @@ export default class JarvisExtension extends Extension {
|
|||||||
this.settings = this.getSettings();
|
this.settings = this.getSettings();
|
||||||
this.proxy = new JarvisProxy();
|
this.proxy = new JarvisProxy();
|
||||||
this.popup = new ConversationView({ compact: true });
|
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.cu = new ComputerUseChrome(); this.cu.attach();
|
||||||
this.session = new SessionPanel(); this.session.attach();
|
this.session = new SessionPanel(); this.session.attach();
|
||||||
this._bindSurface(this.popup);
|
this._bindSurface(this.popup);
|
||||||
@@ -55,7 +64,11 @@ export default class JarvisExtension extends Extension {
|
|||||||
this._applyAccessibility();
|
this._applyAccessibility();
|
||||||
this._removeShellService = installShellService();
|
this._removeShellService = installShellService();
|
||||||
this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant';
|
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');
|
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
|
||||||
this._mountPopup();
|
this._mountPopup();
|
||||||
this._indicator.connect('button-press-event', (_actor, event) => {
|
this._indicator.connect('button-press-event', (_actor, event) => {
|
||||||
@@ -173,28 +186,41 @@ export default class JarvisExtension extends Extension {
|
|||||||
const proxy = this.proxy;
|
const proxy = this.proxy;
|
||||||
try {
|
try {
|
||||||
await proxy.connect(); if (this.proxy !== proxy) return;
|
await proxy.connect(); if (this.proxy !== proxy) return;
|
||||||
this._signals = [
|
if (!this._signals) {
|
||||||
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
this._signals = [
|
||||||
['ContextReset', () => { this._eachView((view) => { view.clear(); view.setNotice('New conversation'); }); }],
|
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
||||||
['WakeHeard', () => this.osd.showWake()],
|
['ContextReset', () => { this._eachView((view) => { view.clear(); view.setNotice('New conversation'); }); }],
|
||||||
['PartialTranscript', (text) => this._eachView((view) => view.addRow('U', text))],
|
['WakeHeard', () => this.osd.showWake()],
|
||||||
['Token', (text) => this._eachView((view) => view.token(text))],
|
['PartialTranscript', (text) => this._eachView((view) => view.addRow('U', text))],
|
||||||
['Thinking', (text) => this._eachView((view) => view.updateThinking(text))],
|
['Token', (text) => this._eachView((view) => view.token(text))],
|
||||||
['ToolCall', (json) => this._eachView((view) => view.addToolCall(json))],
|
['Thinking', (text) => this._eachView((view) => view.updateThinking(text))],
|
||||||
['ToolResult', (json) => this._eachView((view) => view.addToolResult(json))],
|
['ToolCall', (json) => this._eachView((view) => view.addToolCall(json))],
|
||||||
['Reply', (text) => this._eachView((view) => view.finalizeReply(text))],
|
['ToolResult', (json) => this._eachView((view) => view.addToolResult(json))],
|
||||||
['SpeakingLevel', () => {}],
|
['Reply', (text) => this._eachView((view) => view.finalizeReply(text))],
|
||||||
['ListeningLevel', () => {}],
|
['SpeakingLevel', () => {}],
|
||||||
['ChipOffered', (id, label, payload) => this._eachView((view) => view.addChip(id, label, payload))],
|
['ListeningLevel', () => {}],
|
||||||
['JobProgress', (id, pct, label) => this.cu.addJob(id, pct, label)],
|
['ChipOffered', (id, label, payload) => this._eachView((view) => view.addChip(id, label, payload))],
|
||||||
['ComputerStep', (json) => { this.cu.addStep(json); this._eachView((view) => view.addStep(json)); }],
|
['JobProgress', (id, pct, label) => this.cu.addJob(id, pct, label)],
|
||||||
['ComputerHighlight', (json) => this.cu.setTarget(json)],
|
['ComputerStep', (json) => { this.cu.addStep(json); this._eachView((view) => view.addStep(json)); }],
|
||||||
['ConfirmationRequired', (tool, args, pattern) => { this._eachView((view) => view.offerConfirm(tool, args, pattern)); this._openPopup(); }],
|
['ComputerHighlight', (json) => this.cu.setTarget(json)],
|
||||||
['Error', (code, message) => { this._eachView((view) => { view.setNotice(message); view.finishThinking(); }); if (coerceText(code) === 'VOICE_UNAVAILABLE') this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }],
|
['ConfirmationRequired', (tool, args, pattern) => { this._eachView((view) => view.offerConfirm(tool, args, pattern)); this._openPopup(); }],
|
||||||
].map(([name, handler]) => proxy.on(name, handler));
|
['Error', (code, message) => { this._eachView((view) => { view.setNotice(message); view.finishThinking(); }); if (coerceText(code) === 'VOICE_UNAVAILABLE') this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }],
|
||||||
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
|
].map(([name, handler]) => proxy.on(name, handler));
|
||||||
|
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
|
||||||
|
}
|
||||||
await this._syncDaemon();
|
await this._syncDaemon();
|
||||||
} catch (error) { if (this.proxy !== proxy) return; this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
|
} 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() {
|
async _syncDaemon() {
|
||||||
if (!this.proxy) return;
|
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;
|
const result = await this.proxy.call('GetState'); if (!this.popup) return;
|
||||||
this._setState(result.deep_unpack()[0]);
|
this._setState(result.deep_unpack()[0]);
|
||||||
await this._refreshVoiceStatus();
|
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}`); }
|
} catch (error) { this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
|
||||||
}
|
}
|
||||||
async _refreshVoiceStatus() {
|
async _refreshVoiceStatus() {
|
||||||
@@ -220,8 +249,14 @@ export default class JarvisExtension extends Extension {
|
|||||||
} catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }
|
} catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }
|
||||||
}
|
}
|
||||||
_call(name, signature, value) {
|
_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) => {
|
this.proxy.call(name, signature, value).catch((error) => {
|
||||||
log(`Jarvis ${name}: ${error.message}`);
|
log(`Jarvis ${name}: ${error.message}`);
|
||||||
|
if (name === 'PushToTalk') return;
|
||||||
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
|
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
|
||||||
this._eachView((view) => view.setNotice(fallback));
|
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._settingsChanged) this.settings.disconnect(this._settingsChanged);
|
||||||
if (this._styleChanged) this.settings.disconnect(this._styleChanged);
|
if (this._styleChanged) this.settings.disconnect(this._styleChanged);
|
||||||
if (this._menuState) try { this._indicator.menu.disconnect(this._menuState); } catch {}
|
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._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id));
|
||||||
this.proxy?.close();
|
this.proxy?.close();
|
||||||
this.osd?.destroy(); this.cu?.destroy(); this.session?.destroy(); this.popup?.destroy();
|
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 { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||||
import Adw from 'gi://Adw';
|
import { fillSettingsWindow } from './settings-window.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class JarvisPreferences extends ExtensionPreferences {
|
export default class JarvisPreferences extends ExtensionPreferences {
|
||||||
fillPreferencesWindow(window) {
|
fillPreferencesWindow(window) { fillSettingsWindow(window, this.getSettings(), this.path); }
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -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 { max-width: 360px; }
|
||||||
.jarvis-menu-item { padding: 0; }
|
.jarvis-menu-item { padding: 0; }
|
||||||
.jarvis-popup { width: 320px; padding: 12px 14px; spacing: 8px; color: #f6f7fb; }
|
.jarvis-popup { width: 320px; padding: 12px 14px; spacing: 8px; color: #f6f7fb; }
|
||||||
@@ -33,8 +36,6 @@
|
|||||||
.jarvis-settings { font-size: 12px; padding: 4px 8px; }
|
.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, .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-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-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-job, .jarvis-target, .jarvis-cu-step { color: #4FD2FF; font-size: 12px; }
|
||||||
.jarvis-agent-cursor { color: var(--jarvis-accent, #F4B942); font-size: 22px; }
|
.jarvis-agent-cursor { color: var(--jarvis-accent, #F4B942); font-size: 22px; }
|
||||||
|
|||||||
@@ -250,41 +250,50 @@ export class ConversationView {
|
|||||||
destroy() { this.root.destroy(); }
|
destroy() { this.root.destroy(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PANEL_STATES = {
|
||||||
|
LISTENING: 'Listening',
|
||||||
|
SPEAKING: 'Speaking',
|
||||||
|
THINKING: 'Thinking',
|
||||||
|
SLEEPING: 'Privacy',
|
||||||
|
};
|
||||||
|
|
||||||
export class JarvisOsd {
|
export class JarvisOsd {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.root = new St.BoxLayout({ style_class: 'jarvis-osd', visible: false });
|
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-osd-label' });
|
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.add_child(this.label);
|
||||||
this.root.accessible_name = 'Jarvis status overlay';
|
this.root.accessible_name = 'Jarvis status';
|
||||||
this._sticky = false;
|
this._state = 'ARMED';
|
||||||
|
this._wake = false;
|
||||||
this._timeout = 0;
|
this._timeout = 0;
|
||||||
}
|
}
|
||||||
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.hide(); }
|
attach(parent) { parent?.add_child?.(this.root); 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; });
|
|
||||||
}
|
|
||||||
setState(state) {
|
setState(state) {
|
||||||
if (state === 'LISTENING') this.show('Listening', { sticky: true, ms: 8000 });
|
this._state = STATES.has(state) ? state : 'ARMED';
|
||||||
else if (state === 'SPEAKING') this.show('Speaking', { sticky: true, ms: 8000 });
|
this._wake = false;
|
||||||
else if (state === 'SLEEPING') this.show('Privacy mode', { sticky: false, ms: 1600 });
|
this._clearTimer();
|
||||||
else this.hide();
|
this._render();
|
||||||
}
|
}
|
||||||
showWake() { this.show('Wake', { sticky: false, ms: 1200 }); }
|
showWake() {
|
||||||
hide() { this.root.visible = false; this._sticky = false; if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } }
|
this._wake = true;
|
||||||
destroy() { this.hide(); this.root.destroy(); }
|
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 {
|
export class ComputerUseChrome {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { assertSafeTarget, requiresConfirmation } from './safety.js';
|
|||||||
|
|
||||||
export class ComputerActuator {
|
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; }
|
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 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); 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 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 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 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 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 }); }); }
|
async hover(args = {}) { return this.run('hover', args, async () => { this.input.send({ type: 'pointer', action: 'move', x: args.x, y: args.y }); }); }
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { spawn } from 'node:child_process';
|
|||||||
export const MAX_LONG_EDGE = 1280;
|
export const MAX_LONG_EDGE = 1280;
|
||||||
|
|
||||||
export class FrameNormalizer {
|
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) {
|
async normalize(input, output = path.join(this.tmpDir, `frame-${Date.now()}.webp`), rect) {
|
||||||
await mkdir(path.dirname(output), { recursive: true });
|
await mkdir(path.dirname(output), { recursive: true });
|
||||||
const crop = rect ? rect.map(Number).map((value) => String(Math.round(value))) : [];
|
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}`))); });
|
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: MAX_LONG_EDGE, mime: 'image/webp' };
|
const info = await stat(output); return { path: output, bytes: info.size, maxLongEdge: this.maxLongEdge, mime: 'image/webp' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 [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 };
|
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 (!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 (frame.path && 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 && includeVision && this.vision) result.vision_hint = await this.vision(frame.path).catch((error) => { result.unavailable.push(`vision: ${error.message}`); return null; });
|
||||||
return result;
|
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); }
|
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); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,49 @@ import path from 'node:path';
|
|||||||
/** Wayland input boundary. The helper owns portal consent and the EIS fd;
|
/** Wayland input boundary. The helper owns portal consent and the EIS fd;
|
||||||
* Node sends only bounded JSON actions and never uses hidden uinput. */
|
* Node sends only bounded JSON actions and never uses hidden uinput. */
|
||||||
export class PortalInputBackend {
|
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; }
|
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 } = {}) {
|
||||||
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' }; }
|
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`); }
|
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
@@ -1,8 +1,10 @@
|
|||||||
const MAX_STEPS = 100;
|
const MAX_STEPS = 100;
|
||||||
|
|
||||||
export class ComputerUseSession {
|
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.clock = clock;
|
||||||
|
this.mode = mode;
|
||||||
|
this.grantMinutes = grantMinutes;
|
||||||
this.stepsMax = Math.min(MAX_STEPS, Math.max(1, stepsMax));
|
this.stepsMax = Math.min(MAX_STEPS, Math.max(1, stepsMax));
|
||||||
this.active = false;
|
this.active = false;
|
||||||
this.stepsUsed = 0;
|
this.stepsUsed = 0;
|
||||||
@@ -13,10 +15,11 @@ export class ComputerUseSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
grant({ persist = false, monitors = 'focused' } = {}) {
|
grant({ persist = false, monitors = 'focused' } = {}) {
|
||||||
|
if (this.mode === 'off') throw new Error('Desktop access is disabled in Settings');
|
||||||
this.active = true;
|
this.active = true;
|
||||||
this.stepsUsed = 0;
|
this.stepsUsed = 0;
|
||||||
this.sessionId = `cu_${this.clock().toString(36)}`;
|
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 };
|
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.sessionId = null;
|
||||||
this.expiresAt = null;
|
this.expiresAt = null;
|
||||||
this.backend = 'none';
|
this.backend = 'none';
|
||||||
void this.audit?.wipeTemp?.();
|
Promise.resolve(this.audit?.wipeTemp?.()).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
status() {
|
status() {
|
||||||
|
if (this.active && this.expiresAt != null && this.clock() >= this.expiresAt) this.revoke();
|
||||||
return {
|
return {
|
||||||
active: this.active,
|
active: this.active,
|
||||||
|
mode: this.mode,
|
||||||
steps_used: this.stepsUsed,
|
steps_used: this.stepsUsed,
|
||||||
steps_max: this.stepsMax,
|
steps_max: this.stepsMax,
|
||||||
grant_expires_at: this.expiresAt,
|
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.active) throw new Error('computer-use grant is inactive');
|
||||||
if (this.expiresAt && this.clock() >= this.expiresAt) {
|
if (this.expiresAt && this.clock() >= this.expiresAt) {
|
||||||
this.revoke();
|
this.revoke();
|
||||||
throw new Error('computer-use grant expired');
|
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');
|
if (this.stepsUsed >= this.stepsMax) throw new Error('computer-use step budget exhausted');
|
||||||
this.stepsUsed += 1;
|
this.stepsUsed += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ export const MIC_FORMAT = 's16';
|
|||||||
/** Raw 16 kHz mono capture from PipeWire. The process is deliberately kept
|
/** 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. */
|
* outside gnome-shell and has a stable node name for routing in Helvum. */
|
||||||
export class PipeWireCapture extends EventEmitter {
|
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();
|
super();
|
||||||
this.command = command;
|
this.command = command;
|
||||||
this.spawnImpl = spawnImpl;
|
this.spawnImpl = spawnImpl;
|
||||||
this.sampleRate = sampleRate;
|
this.sampleRate = sampleRate;
|
||||||
this.nodeName = nodeName;
|
this.nodeName = nodeName;
|
||||||
|
this.target = target;
|
||||||
this.process = null;
|
this.process = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ export class PipeWireCapture extends EventEmitter {
|
|||||||
if (this.process) return this;
|
if (this.process) return this;
|
||||||
this.process = this.spawnImpl(this.command, [
|
this.process = this.spawnImpl(this.command, [
|
||||||
'--record', '--raw', '--format', MIC_FORMAT, '--rate', String(this.sampleRate),
|
'--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'] });
|
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
this.process.stdout?.on('data', (chunk) => this.emit('audio', Buffer.from(chunk)));
|
this.process.stdout?.on('data', (chunk) => this.emit('audio', Buffer.from(chunk)));
|
||||||
this.process.stderr?.on('data', (chunk) => this.emit('diagnostic', String(chunk).trim()));
|
this.process.stderr?.on('data', (chunk) => this.emit('diagnostic', String(chunk).trim()));
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { spawn } from 'node:child_process';
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
export class PipeWirePlayback extends EventEmitter {
|
export class PipeWirePlayback extends EventEmitter {
|
||||||
constructor({ command = 'pw-cat', spawnImpl = spawn, sampleRate = 44_100, nodeName = 'Jarvis' } = {}) {
|
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.process = null;
|
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();
|
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 = '';
|
let diagnostic = '';
|
||||||
child.stderr?.on('data', (chunk) => { diagnostic = (diagnostic + String(chunk)).slice(-2048); this.emit('diagnostic', String(chunk).trim()); });
|
child.stderr?.on('data', (chunk) => { diagnostic = (diagnostic + String(chunk)).slice(-2048); this.emit('diagnostic', String(chunk).trim()); });
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ export async function serveOnSessionBus(daemon) {
|
|||||||
PushToTalk(pressed) { daemon.setPushToTalk?.(Boolean(pressed)); }
|
PushToTalk(pressed) { daemon.setPushToTalk?.(Boolean(pressed)); }
|
||||||
async Say(text) { await daemon.say?.(text); }
|
async Say(text) { await daemon.say?.(text); }
|
||||||
async Ask(text) { await daemon.ask(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(); }
|
async ResetContext() { await daemon.resetContext(); }
|
||||||
Confirm(jobId, toolCallId, decision) { daemon.confirmPermission?.(jobId, toolCallId, decision); }
|
Confirm(jobId, toolCallId, decision) { daemon.confirmPermission?.(jobId, toolCallId, decision); }
|
||||||
Cancel() { daemon.cancel(); }
|
Cancel() { daemon.cancel(); }
|
||||||
@@ -64,6 +67,9 @@ export async function serveOnSessionBus(daemon) {
|
|||||||
Session.configureMembers({
|
Session.configureMembers({
|
||||||
methods: {
|
methods: {
|
||||||
Arm: { inSignature: '', outSignature: '' },
|
Arm: { inSignature: '', outSignature: '' },
|
||||||
|
ReloadSettings: { inSignature: '', outSignature: 's' },
|
||||||
|
PreviewVoice: { inSignature: 's', outSignature: '' },
|
||||||
|
StopSpeech: { inSignature: '', outSignature: '' },
|
||||||
Sleep: { inSignature: '', outSignature: '', method: 'Sleep' },
|
Sleep: { inSignature: '', outSignature: '', method: 'Sleep' },
|
||||||
Shutdown: { inSignature: '', outSignature: '', method: 'Shutdown' },
|
Shutdown: { inSignature: '', outSignature: '', method: 'Shutdown' },
|
||||||
PushToTalk: { inSignature: 'b', outSignature: '', method: 'PushToTalk' },
|
PushToTalk: { inSignature: 'b', outSignature: '', method: 'PushToTalk' },
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ try {
|
|||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
} finally {
|
} finally {
|
||||||
// Resource discovery initializes QVAC handles even without loading a model.
|
// Resource discovery initializes QVAC handles even without loading a model.
|
||||||
// This one-shot command owns them and must close them before returning to
|
// This one-shot command owns them and must close them before returning.
|
||||||
// first-run. Bound cleanup in case a native handle never settles.
|
// Bound cleanup in case a native handle never settles.
|
||||||
const watchdog = setTimeout(() => {
|
const watchdog = setTimeout(() => {
|
||||||
console.error('gpu-doctor: QVAC cleanup timed out');
|
console.error('gpu-doctor: QVAC cleanup timed out');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
+15
-11
@@ -1,5 +1,6 @@
|
|||||||
|
import { voiceSettings } from './voice-settings.js';
|
||||||
import { EventEmitter } from 'node:events';
|
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 { createRuntimeTools } from '../skills/runtime-tools.js';
|
||||||
import { createPhase2Tools } from '../skills/phase2-tools.js';
|
import { createPhase2Tools } from '../skills/phase2-tools.js';
|
||||||
import { createQvacTools } from '../skills/qvac-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';
|
import { createPhase9GatewayTool } from '../skills/phase9-tools.js';
|
||||||
|
|
||||||
export class HarnessBridge extends EventEmitter {
|
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();
|
super();
|
||||||
|
const settings = voiceSettings();
|
||||||
this.options = {
|
this.options = {
|
||||||
cwd,
|
cwd,
|
||||||
model,
|
model,
|
||||||
@@ -29,9 +31,9 @@ export class HarnessBridge extends EventEmitter {
|
|||||||
origin: 'jarvis-qvac',
|
origin: 'jarvis-qvac',
|
||||||
system: VOICE_SYSTEM_PROMPT,
|
system: VOICE_SYSTEM_PROMPT,
|
||||||
voice: true,
|
voice: true,
|
||||||
maxTurns: 6,
|
maxTurns: settings.maxTurns,
|
||||||
maxShellCalls: 1,
|
maxShellCalls: settings.maxShellCalls,
|
||||||
maxToolRounds: 4,
|
maxToolRounds: settings.maxToolRounds,
|
||||||
};
|
};
|
||||||
this.session = null;
|
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}`);
|
throw new Error(`Jarvis uses one QVAC master model (${process.env.JARVIS_QVAC_MODEL}); requested ${this.options.model}`);
|
||||||
}
|
}
|
||||||
await acquireQvac();
|
await acquireQvac();
|
||||||
|
this._acquired = true;
|
||||||
try {
|
try {
|
||||||
this.session = await Agent.create(this.options);
|
this.session = await Agent.create(this.options);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
releaseQvac();
|
releaseQvac();
|
||||||
|
this._acquired = false;
|
||||||
await closeQvac();
|
await closeQvac();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -93,14 +97,14 @@ export class HarnessBridge extends EventEmitter {
|
|||||||
cancel() { this.session?.cancel(); }
|
cancel() { this.session?.cancel(); }
|
||||||
|
|
||||||
async resetContext() {
|
async resetContext() {
|
||||||
this.session?.cancel?.();
|
try { this.session?.cancel?.(); await this.session?.dispose?.(); }
|
||||||
await this.session?.dispose?.();
|
finally {
|
||||||
this.session = null;
|
this.session = null;
|
||||||
|
if (this._acquired) { releaseQvac(); this._acquired = false; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
await this.session?.dispose();
|
try { await this.resetContext(); } finally { await closeQvac(); }
|
||||||
releaseQvac();
|
|
||||||
await closeQvac();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-20
@@ -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 { EventEmitter } from 'node:events';
|
||||||
import { HarnessBridge } from './harness-bridge.js';
|
import { HarnessBridge } from './harness-bridge.js';
|
||||||
import { ComputerUseSession } from '../computer-use/session.js';
|
import { ComputerUseSession } from '../computer-use/session.js';
|
||||||
@@ -22,13 +27,15 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.recovery = new StateRecovery(); const restored = this.recovery.load(); this.state = restored.state; this.mode = restored.mode;
|
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.scheduler = new QvacScheduler({ concurrency: 1 });
|
||||||
this.audit = new ComputerAudit();
|
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.input = new PortalInputBackend();
|
||||||
this.perception = new QvacPerception();
|
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.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.harness = new HarnessBridge({ cwd: process.cwd(), computer: this.computer, observer: this.observer, actuator: this.actuator });
|
||||||
this.log = new PrivacyLog();
|
this.log = new PrivacyLog();
|
||||||
@@ -36,6 +43,7 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
this.lastReply = '';
|
this.lastReply = '';
|
||||||
this.voiceLoop = null;
|
this.voiceLoop = null;
|
||||||
this._activeAsk = null;
|
this._activeAsk = null;
|
||||||
|
this._askGeneration = 0;
|
||||||
this._idleTimer = setInterval(() => this.tickIdle(), 30_000);
|
this._idleTimer = setInterval(() => this.tickIdle(), 30_000);
|
||||||
this._idleTimer.unref?.();
|
this._idleTimer.unref?.();
|
||||||
this.telemetry = new RuntimeTelemetry();
|
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(() => {}); }
|
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 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) {
|
say(text) {
|
||||||
const spoken = spokenReply(text) || spokenReply(this.lastReply) || 'There is nothing to repeat.';
|
const spoken = spokenReply(text) || spokenReply(this.lastReply) || 'There is nothing to repeat.';
|
||||||
this.lastReply = spoken;
|
this.lastReply = spoken;
|
||||||
@@ -59,6 +67,9 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
this._speakReply(spoken);
|
this._speakReply(spoken);
|
||||||
}
|
}
|
||||||
async ask(text) {
|
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?.();
|
this.voiceLoop?.interrupt?.();
|
||||||
try {
|
try {
|
||||||
this.voice.typedUtterance(); this.setState('THINKING');
|
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' });
|
const job = this.scheduler.run(() => this.harness.ask(text), { lane: 'voice' });
|
||||||
this._activeAsk = job;
|
this._activeAsk = job;
|
||||||
const reply = await job;
|
const reply = await job;
|
||||||
|
if (generation !== this._askGeneration || this.locked) return '';
|
||||||
this.telemetry.record('llm', startedAt, { success: true });
|
this.telemetry.record('llm', startedAt, { success: true });
|
||||||
const spoken = spokenReply(reply);
|
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;
|
this._beginSpeech(); this.lastReply = spoken; this.emit('Reply', spoken); this._speakReply(spoken); return spoken;
|
||||||
} catch (error) {
|
} 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;
|
this.telemetry.record('llm', Date.now(), { success: false }); this.emit('Error', 'QVAC', error.message); this.voice.cancel(); this.setState('ARMED'); throw error;
|
||||||
} finally {
|
} finally {
|
||||||
this._activeAsk = null;
|
if (generation === this._askGeneration) this._activeAsk = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async resetContext() {
|
async resetContext() {
|
||||||
|
this._askGeneration += 1;
|
||||||
this.harness.cancel();
|
this.harness.cancel();
|
||||||
this.scheduler.cancelQueued((job) => job.lane === 'voice');
|
this.scheduler.cancelQueued((job) => job.lane === 'voice');
|
||||||
await this._activeAsk?.catch?.(() => {});
|
await this._activeAsk?.catch?.(() => {});
|
||||||
@@ -99,15 +113,18 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
}
|
}
|
||||||
_finishSpeech() {
|
_finishSpeech() {
|
||||||
try { this.voice.finishSpeaking(); } catch {}
|
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) {
|
_speakReply(spoken) {
|
||||||
|
const generation = this._askGeneration;
|
||||||
if (!this.voiceLoop) {
|
if (!this.voiceLoop) {
|
||||||
this._finishSpeech();
|
this._finishSpeech();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const play = async () => {
|
const play = async () => {
|
||||||
await this.ensureTts();
|
await this.ensureTts();
|
||||||
|
if (generation !== this._askGeneration || this.locked) return;
|
||||||
if (!this.voiceLoop?.status?.tts) {
|
if (!this.voiceLoop?.status?.tts) {
|
||||||
const reason = this.voiceLoop?.status?.errors?.tts;
|
const reason = this.voiceLoop?.status?.errors?.tts;
|
||||||
if (reason && reason !== 'Spoken replies are disabled') this.emit('Error', 'TTS', reason);
|
if (reason && reason !== 'Spoken replies are disabled') this.emit('Error', 'TTS', reason);
|
||||||
@@ -119,14 +136,15 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.emit('Error', 'TTS', error.message);
|
this.emit('Error', 'TTS', error.message);
|
||||||
})
|
})
|
||||||
.finally(() => this._finishSpeech());
|
.finally(() => { if (generation === this._askGeneration) this._finishSpeech(); });
|
||||||
}
|
}
|
||||||
async ensureTts() {
|
async ensureTts() {
|
||||||
|
await this._voiceStarting;
|
||||||
if (!this.voiceLoop) return;
|
if (!this.voiceLoop) return;
|
||||||
const settings = voiceSettings();
|
const settings = this.settings;
|
||||||
if (!settings.ttsEnabled) return;
|
if (!settings.ttsEnabled) { this.voiceLoop.status.tts = false; return; }
|
||||||
if (this.voiceLoop.status.tts) 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 {
|
try {
|
||||||
await this.voiceLoop.tts.start();
|
await this.voiceLoop.tts.start();
|
||||||
this.voiceLoop.status.tts = true;
|
this.voiceLoop.status.tts = true;
|
||||||
@@ -139,16 +157,26 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
this.emit('Error', 'TTS', message);
|
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)); }
|
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 })); 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; }
|
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' })); }
|
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;
|
if (this.voiceLoop) return;
|
||||||
const settings = voiceSettings();
|
const settings = this.settings;
|
||||||
const asr = new QvacVoiceAdapter({ role: 'asr' });
|
const asr = settings.microphoneEnabled ? new QvacVoiceAdapter({ role: 'asr', settings }) : null;
|
||||||
const tts = settings.ttsEnabled ? new QvacVoiceAdapter({ role: 'tts' }) : null;
|
const tts = settings.ttsEnabled ? new QvacVoiceAdapter({ role: 'tts', settings }) : null;
|
||||||
if (!settings.ttsEnabled) console.log('jarvisd: voice: TTS disabled in config.json');
|
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); });
|
loop.on('error', (error) => { console.error(`jarvisd: voice: ${error.message}`); this.emit('Error', 'VOICE', error.message); });
|
||||||
this.voiceLoop = loop;
|
this.voiceLoop = loop;
|
||||||
try { await loop.start(); this.emit('StateChanged', this.state); } catch (error) {
|
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');
|
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}`);
|
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)); }
|
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 assessModelFit(model) { return JSON.stringify(await callQvac('assessModelFit', { modelSrc: String(model) })); }
|
||||||
async downloadModel(model) { return JSON.stringify(await callQvac('downloadAsset', { 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 cancelModel(model) { return JSON.stringify(await cancelQvacRequest({ modelId: String(model) })); }
|
||||||
async wipeComputerTraces() { await this.audit.wipeTemp(); return true; }
|
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); }
|
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(); }
|
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() {
|
export async function startDaemon() {
|
||||||
|
|||||||
+9
-4
@@ -8,22 +8,27 @@ const MAX_LINE = 64 * 1024;
|
|||||||
export async function createEventSocket({ socketPath, onMessage }) {
|
export async function createEventSocket({ socketPath, onMessage }) {
|
||||||
await mkdir(path.dirname(socketPath), { recursive: true });
|
await mkdir(path.dirname(socketPath), { recursive: true });
|
||||||
try { await rm(socketPath, { force: true }); } catch {}
|
try { await rm(socketPath, { force: true }); } catch {}
|
||||||
|
const clients = new Set();
|
||||||
const server = net.createServer((socket) => {
|
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 = '';
|
let buffer = '';
|
||||||
socket.setEncoding('utf8');
|
socket.setEncoding('utf8');
|
||||||
socket.on('data', (chunk) => {
|
socket.on('data', (chunk) => {
|
||||||
buffer += chunk;
|
buffer += chunk;
|
||||||
if (buffer.length > MAX_LINE * 2) { socket.destroy(new Error('IPC buffer too large')); return; }
|
|
||||||
let index;
|
let index;
|
||||||
while ((index = buffer.indexOf('\n')) >= 0) {
|
while ((index = buffer.indexOf('\n')) >= 0) {
|
||||||
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
|
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; }
|
if (Buffer.byteLength(line) > 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'); }
|
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); });
|
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 = {}) {
|
export function sendEvent(socket, event, payload = {}) {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
+21
-27
@@ -1,3 +1,5 @@
|
|||||||
|
import { voiceSettings } from './voice-settings.js';
|
||||||
|
import { profile } from './model-profiles.js';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -17,7 +19,7 @@ const auxiliaryModels = new Map();
|
|||||||
|
|
||||||
export const QVAC_MASTER = Object.freeze({
|
export const QVAC_MASTER = Object.freeze({
|
||||||
configPath: process.env.QVAC_CONFIG_PATH,
|
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',
|
device: 'gpu',
|
||||||
gpuLayers: 99,
|
gpuLayers: 99,
|
||||||
});
|
});
|
||||||
@@ -45,34 +47,26 @@ export function assertSdkVersion() {
|
|||||||
|
|
||||||
export async function acquireQvac({ auxiliaryOnly = false } = {}) {
|
export async function acquireQvac({ auxiliaryOnly = false } = {}) {
|
||||||
if (auxiliaryOnly) { await qvacSdk(); ownerCount += 1; return; }
|
if (auxiliaryOnly) { await qvacSdk(); ownerCount += 1; return; }
|
||||||
ownerCount += 1;
|
|
||||||
if (!loadPromise) {
|
if (!loadPromise) {
|
||||||
assertSdkVersion();
|
// Publish the promise before the first await so concurrent callers share
|
||||||
const resources = await Agent.engine.resources();
|
// resource discovery and loading. Count only successful acquisitions.
|
||||||
const gpuVisible = (resources.gpus?.length || 0) > 0 ||
|
loadPromise = Promise.resolve().then(async () => {
|
||||||
Boolean(resources.drivers?.vulkan || resources.drivers?.cuda || resources.drivers?.opencl || resources.gpu);
|
assertSdkVersion();
|
||||||
if (!gpuVisible) {
|
const resources = await Agent.engine.resources();
|
||||||
ownerCount = Math.max(0, ownerCount - 1);
|
const gpuVisible = (resources.gpus?.length || 0) > 0 ||
|
||||||
throw new Error('Jarvis requires a QVAC-visible GPU backend; run npm run gpu-doctor');
|
Boolean(resources.drivers?.vulkan || resources.drivers?.cuda || resources.drivers?.opencl || resources.gpu);
|
||||||
}
|
if (!gpuVisible) throw new Error('Jarvis requires a QVAC-visible GPU backend; run npm run gpu-doctor');
|
||||||
loadPromise = Agent.engine.load({
|
const loaded = await Agent.engine.load({
|
||||||
model: QVAC_MASTER.model,
|
model: QVAC_MASTER.model, tools: true, device: 'gpu',
|
||||||
tools: true,
|
gpu_layers: QVAC_MASTER.gpuLayers, mmprojUseGpu: true,
|
||||||
device: 'gpu',
|
});
|
||||||
gpu_layers: QVAC_MASTER.gpuLayers,
|
if (loaded.device !== 'gpu') throw new Error(`Jarvis requires GPU QVAC inference; loaded device was ${loaded.device}`);
|
||||||
mmprojUseGpu: true,
|
|
||||||
}).then((loaded) => {
|
|
||||||
if (loaded.device !== 'gpu') {
|
|
||||||
throw new Error(`Jarvis requires GPU QVAC inference; loaded device was ${loaded.device}`);
|
|
||||||
}
|
|
||||||
return loaded;
|
return loaded;
|
||||||
}).catch((error) => {
|
}).catch((error) => { loadPromise = null; throw error; });
|
||||||
loadPromise = null;
|
|
||||||
ownerCount = Math.max(0, ownerCount - 1);
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return loadPromise;
|
const loaded = await loadPromise;
|
||||||
|
ownerCount += 1;
|
||||||
|
return loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function qvacSdk() {
|
export async function qvacSdk() {
|
||||||
@@ -92,7 +86,7 @@ function resolveSdkAsset(sdk, name) {
|
|||||||
|
|
||||||
function resolveModelConfigAssets(sdk, config) {
|
function resolveModelConfigAssets(sdk, config) {
|
||||||
const copy = { ...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]);
|
if (typeof copy[key] === 'string') copy[key] = resolveSdkAsset(sdk, copy[key]);
|
||||||
}
|
}
|
||||||
return copy;
|
return copy;
|
||||||
|
|||||||
@@ -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
@@ -20,7 +20,7 @@ export class VadSegmenter extends EventEmitter {
|
|||||||
this.reset();
|
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) {
|
push(frame) {
|
||||||
const chunk = Buffer.from(frame || '');
|
const chunk = Buffer.from(frame || '');
|
||||||
@@ -34,11 +34,12 @@ export class VadSegmenter extends EventEmitter {
|
|||||||
}
|
}
|
||||||
if (!this.speaking) return;
|
if (!this.speaking) return;
|
||||||
this.buffer.push(chunk);
|
this.buffer.push(chunk);
|
||||||
|
this.recordingMs += durationMs;
|
||||||
if (voiced) { this.speechMs += durationMs; this.silenceMs = 0; }
|
if (voiced) { this.speechMs += durationMs; this.silenceMs = 0; }
|
||||||
else { this.silenceMs += durationMs; }
|
else { this.silenceMs += durationMs; }
|
||||||
if (this.speechMs >= this.params.maxSpeechDurationMs ||
|
if (this.recordingMs >= this.params.maxSpeechDurationMs || this.silenceMs >= this.params.minSilenceDurationMs) {
|
||||||
(this.speechMs >= this.params.minSpeechDurationMs && this.silenceMs >= this.params.minSilenceDurationMs)) {
|
if (this.speechMs >= this.params.minSpeechDurationMs) this.end();
|
||||||
this.end();
|
else this.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { voiceSettings } from './voice-settings.js';
|
||||||
|
import { ttsConfiguration, scaleSpeech } from './tts-config.js';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { acquireQvac, releaseQvac, loadAuxiliaryModel, unloadAuxiliaryModel, qvacSdk, withQvacMaster, assertSdkVersion } from './qvac-master.js';
|
import { acquireQvac, releaseQvac, loadAuxiliaryModel, unloadAuxiliaryModel, qvacSdk, withQvacMaster, assertSdkVersion } from './qvac-master.js';
|
||||||
|
|
||||||
@@ -20,8 +22,11 @@ function toUint8(samples) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class QvacVoiceAdapter extends EventEmitter {
|
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' } = {}) {
|
constructor({ role = 'both', settings = voiceSettings(), asrModel = settings.asrModel, ttsModel } = {}) {
|
||||||
super(); this.role = role; this.asrModel = asrModel; this.ttsModel = ttsModel; this.asrId = null; this.ttsId = null; this.asrSession = null; this.acquired = false;
|
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() {
|
async start() {
|
||||||
@@ -29,8 +34,8 @@ export class QvacVoiceAdapter extends EventEmitter {
|
|||||||
assertSdkVersion();
|
assertSdkVersion();
|
||||||
await acquireQvac({ auxiliaryOnly: true }); this.acquired = true;
|
await acquireQvac({ auxiliaryOnly: true }); this.acquired = true;
|
||||||
try {
|
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 !== '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, { ttsEngine: 'supertonic', language: 'en', voice: 'F1', ttsSpeed: 1.05, ttsNumInferenceSteps: 5 }, 'tts-ggml');
|
if (this.role !== 'asr') this.ttsId = await loadAuxiliaryModel(this.ttsModel, this.ttsConfig.config, 'tts-ggml');
|
||||||
} catch (error) { await this.stop(); throw error; }
|
} catch (error) { await this.stop(); throw error; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +63,9 @@ export class QvacVoiceAdapter extends EventEmitter {
|
|||||||
}
|
}
|
||||||
return result;
|
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() {
|
async stop() {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { voiceSettings } from './voice-settings.js';
|
||||||
|
import { ttsConfiguration } from './tts-config.js';
|
||||||
import { access } from 'node:fs/promises';
|
import { access } from 'node:fs/promises';
|
||||||
|
|
||||||
const commandAvailable = async (command) => {
|
const commandAvailable = async (command) => {
|
||||||
@@ -7,13 +9,18 @@ const commandAvailable = async (command) => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
const pipewire = await commandAvailable('pw-cat');
|
const pipewire = await commandAvailable('pw-cat');
|
||||||
|
const settings = voiceSettings();
|
||||||
const report = {
|
const report = {
|
||||||
pipewireCapture: pipewire,
|
pipewireCapture: pipewire,
|
||||||
wakeEngine: Boolean(process.env.JARVIS_WAKE_COMMAND),
|
wakeEngine: settings.listeningMode !== 'ptt' && Boolean(settings.command),
|
||||||
wakeCommand: process.env.JARVIS_WAKE_COMMAND || null,
|
wakeCommand: settings.command || null,
|
||||||
sampleRate: 16000,
|
sampleRate: 16000,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
ttsPlayback: pipewire,
|
ttsPlayback: pipewire,
|
||||||
|
microphoneEnabled: settings.microphoneEnabled,
|
||||||
|
inputTarget: settings.inputTarget || 'system default',
|
||||||
|
outputTarget: settings.outputTarget || 'system default',
|
||||||
|
speech: settings.ttsEnabled ? ttsConfiguration(settings) : 'disabled',
|
||||||
gpuRequired: true,
|
gpuRequired: true,
|
||||||
};
|
};
|
||||||
console.log(JSON.stringify(report, null, 2));
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
|||||||
+65
-16
@@ -20,18 +20,23 @@ const FAST_COMMANDS = new Map([
|
|||||||
export function fastCommand(text) { return FAST_COMMANDS.get(String(text || '').trim().toLowerCase()) || null; }
|
export function fastCommand(text) { return FAST_COMMANDS.get(String(text || '').trim().toLowerCase()) || null; }
|
||||||
|
|
||||||
export class VoiceLoop extends EventEmitter {
|
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() } = {}) {
|
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.now = 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.listeningMode = listeningMode; 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();
|
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('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('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.emit('error', new Error('Microphone stream closed')); } });
|
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('unavailable', () => { this.status.wake = false; });
|
||||||
wake.on('error', (error) => { this.status.wake = false; this.emit('error', error); });
|
wake.on('error', (error) => { this.status.wake = false; this.emit('error', error); });
|
||||||
this.status = { asr: false, tts: false, capture: false, wake: false, errors: {} };
|
this.status = { asr: false, tts: false, capture: false, wake: false, errors: {} };
|
||||||
wake.on('wake', (phrase) => this.wakeHeard(phrase));
|
wake.on('wake', (phrase) => this.wakeHeard(phrase));
|
||||||
vad.on('level', (rms) => daemon?.emit('ListeningLevel', rms));
|
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() {
|
async start() {
|
||||||
@@ -48,20 +53,62 @@ export class VoiceLoop extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.running = true;
|
this.running = true;
|
||||||
if (this.status.asr) {
|
if (this.status.asr) {
|
||||||
try { this.capture.start(); this.status.capture = true; }
|
this._armCapture();
|
||||||
catch (error) { this.status.errors.capture = error.message; this.emit('error', error); }
|
this._armWake();
|
||||||
try { this.wake.start?.(); this.wake.resume(); this.status.wake = Boolean(this.wake.command || this.wake.detect); }
|
} else if (this.asr) {
|
||||||
catch (error) { this.status.errors.wake = error.message; this.emit('error', error); }
|
this._scheduleAsrRetry();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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?.(); }
|
_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?.();
|
||||||
|
}
|
||||||
setPushToTalk(pressed) { this.ptt = Boolean(pressed) && this.status.asr && this.status.capture; if (this.ptt) this.wakeHeard('push-to-talk'); else this.vad.end(); }
|
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) {
|
pushAudio(chunk) {
|
||||||
if (!this.running || this.daemon?.locked || this.daemon?.state === 'SLEEPING') return;
|
if (!this.running || this.daemon?.locked || this.daemon?.state === 'SLEEPING') return;
|
||||||
if (this.isSpeaking || this.now() < this.cooldownUntil) { this.metrics.feedbackDrop(); 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 {}
|
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.listeningMode !== 'ptt') this.wake.push(chunk);
|
||||||
if (this.ptt || this.daemon?.state === 'LISTENING') this.vad.push(chunk);
|
if (this.ptt || (this.listeningMode !== 'ptt' && this.daemon?.state === 'LISTENING')) this.vad.push(chunk);
|
||||||
}
|
}
|
||||||
wakeHeard(phrase) {
|
wakeHeard(phrase) {
|
||||||
if (!this.running || this.daemon?.locked) return;
|
if (!this.running || this.daemon?.locked) return;
|
||||||
@@ -69,8 +116,9 @@ export class VoiceLoop extends EventEmitter {
|
|||||||
}
|
}
|
||||||
async transcribe(audio) {
|
async transcribe(audio) {
|
||||||
if (!this.status.asr || !this.asr?.transcribeAudio) return;
|
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 ''; });
|
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; }
|
if (!isMeaningfulTranscript(text)) { this.metrics.wakeRejected(); return; }
|
||||||
this.metrics.utterance();
|
this.metrics.utterance();
|
||||||
this.daemon?.emit('PartialTranscript', text); this.daemon?.emit('FinalTranscript', text);
|
this.daemon?.emit('PartialTranscript', text); this.daemon?.emit('FinalTranscript', text);
|
||||||
@@ -93,7 +141,8 @@ export class VoiceLoop extends EventEmitter {
|
|||||||
this.wake.resume();
|
this.wake.resume();
|
||||||
if (this.daemon?.state === 'SPEAKING') {
|
if (this.daemon?.state === 'SPEAKING') {
|
||||||
try { this.daemon.voice?.finishSpeaking?.(); } catch {}
|
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) {
|
async speak(text) {
|
||||||
@@ -127,7 +176,7 @@ export class VoiceLoop extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const audio = await this.tts.speak(speakableForTts(text));
|
const audio = await this.tts.speak(speakableForTts(text));
|
||||||
if (generation !== this._generation) return;
|
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 {}
|
try { this.daemon?.emit('SpeakingLevel', 0); } catch {}
|
||||||
} finally {
|
} finally {
|
||||||
this.isSpeaking = false;
|
this.isSpeaking = false;
|
||||||
|
|||||||
+16
-17
@@ -1,25 +1,24 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
import { normalizeSettings } from '../apps/gnome-extension/[email protected]/settings-values.js';
|
||||||
|
|
||||||
function flag(value, fallback = true) {
|
export const SETTINGS_FIELDS = JSON.parse(fs.readFileSync(new URL('../apps/gnome-extension/[email protected]/settings-catalog.json', import.meta.url).pathname, 'utf8')).fields;
|
||||||
if (value === undefined || value === null) return fallback;
|
export const configPath = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'jarvis/config.json');
|
||||||
if (typeof value === 'string') return !['false', '0', 'off', 'no'].includes(value.trim().toLowerCase());
|
export function readSettings({ strict = false } = {}) {
|
||||||
return value !== 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, { strict = false } = {}) {
|
||||||
export function voiceSettings(source = null) {
|
const settings = normalizeSettings(source ?? readSettings({ strict }), SETTINGS_FIELDS, { strict });
|
||||||
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'];
|
|
||||||
return {
|
return {
|
||||||
command: process.env.JARVIS_WAKE_COMMAND || config.wakeCommand || config.wake_command || '',
|
...settings,
|
||||||
phrases: [phrase, ...aliases].map((item) => String(item || '').trim()).filter(Boolean),
|
command: process.env.JARVIS_WAKE_COMMAND || settings.wakeCommand,
|
||||||
ttsEnabled: flag(config.ttsEnabled ?? config.tts_enabled, true),
|
phrases: [settings.wakePhrase, ...settings.aliases].map(item => item.trim()).filter(Boolean),
|
||||||
modelProfile: config.modelProfile || config.model_profile || 'laptop-16gb',
|
asrModel: process.env.JARVIS_ASR_MODEL || settings.asrModel,
|
||||||
|
ttsModel: process.env.JARVIS_TTS_MODEL || settings.ttsModel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export class VoiceStateMachine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expireIdle() {
|
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;
|
return this.state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<method name="PushToTalk"><arg name="pressed" type="b" direction="in"/></method>
|
<method name="PushToTalk"><arg name="pressed" type="b" direction="in"/></method>
|
||||||
<method name="Say"><arg name="text" type="s" 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="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="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="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>
|
<method name="Cancel"/><method name="SetMode"><arg name="mode" type="s" direction="in"/></method>
|
||||||
|
|||||||
@@ -24,3 +24,13 @@ hand.
|
|||||||
The uninstall command requires `--yes` and preserves data. Add
|
The uninstall command requires `--yes` and preserves data. Add
|
||||||
`--purge-data` only when the user explicitly wants Jarvis-owned memory, cache,
|
`--purge-data` only when the user explicitly wants Jarvis-owned memory, cache,
|
||||||
and generated media removed.
|
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.
|
||||||
|
|||||||
@@ -55,3 +55,5 @@ must be verified on the target GNOME session.
|
|||||||
- `jarvisd` means the user service and its packaged Bare process.
|
- `jarvisd` means the user service and its packaged Bare process.
|
||||||
- “Master” means the singleton in `daemon/qvac-master.js`.
|
- “Master” means the singleton in `daemon/qvac-master.js`.
|
||||||
- Mermaid diagrams use Gitea-compatible fenced Markdown.
|
- Mermaid diagrams use Gitea-compatible fenced Markdown.
|
||||||
|
|
||||||
|
- [Settings and voice customization](settings.md)
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
2. Run `npm ci`, `npm test`, and `npm run package:test`.
|
2. Run `npm ci`, `npm test`, and `npm run package:test`.
|
||||||
3. Review `dist/SHA256SUMS` and test the user installer in a disposable user
|
3. Review `dist/SHA256SUMS` and test the user installer in a disposable user
|
||||||
session with `packaging/install.sh --enable`.
|
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
|
## Gitea rolling release
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| `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 |
|
| 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 |
|
| 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 |
|
| QVAC master | SDK worker, GPU admission, lifecycle, serialization, cancellation | cloud APIs or independent model instances |
|
||||||
|
|||||||
+2
-3
@@ -14,12 +14,11 @@ On a logged-in GNOME session with a visible QVAC GPU backend:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash packaging/install.sh --enable
|
bash packaging/install.sh --enable
|
||||||
bash packaging/first-run.sh
|
|
||||||
systemctl --user status jarvisd.service
|
systemctl --user status jarvisd.service
|
||||||
gnome-extensions enable jarvis@qvac.local
|
gnome-extensions enable jarvis@qvac.local
|
||||||
```
|
```
|
||||||
|
|
||||||
Complete the typed prompt first, then say “Hey Jarvis, what’s on my screen?”
|
Complete the typed prompt from the tray, then say “Hey Jarvis, what’s on my
|
||||||
and approve a computer-use grant only for the acceptance actions in
|
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
|
`docs/cu-acceptance.md`. If the doctor reports no GPU, stop at diagnostics and
|
||||||
fix the QVAC runtime/driver path before attempting inference.
|
fix the QVAC runtime/driver path before attempting inference.
|
||||||
|
|||||||
@@ -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 (F1–F5 or M1–M5), then adjust speed,
|
||||||
|
quality steps, and volume. Start at speed 1.0–1.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.25–4.
|
||||||
|
- **Voice quality steps** — `ttsSteps`, default `5`. More steps can improve quality but take longer. Range: 1–30.
|
||||||
|
- **Reply volume (%)** — `ttsVolume`, default `100`. Relative to the system speaker volume. Does not change other apps. Range: 0–100.
|
||||||
|
- **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 model’s default voice.
|
||||||
|
- **Synthesis quality steps** — `ttsCfmSteps`, default `2`. Chatterbox: fewer steps respond faster. Range: 1–10.
|
||||||
|
- **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: 0–2.
|
||||||
|
- **Voice seed** — `ttsSeed`, default `42`. Use a fixed seed for repeatable speech generation. Range: 0–2147483647.
|
||||||
|
- **Speech CPU threads** — `ttsThreads`, default `4`. Limit CPU work for Chatterbox and Parler. Range: 1–32.
|
||||||
|
- **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: 1–240.
|
||||||
|
|
||||||
|
### Detection tuning
|
||||||
|
|
||||||
|
- **Speech detection threshold** — `vadThreshold`, default `0.6`. Lower picks up quieter speech; higher rejects more background noise. Range: 0.05–1.
|
||||||
|
- **Minimum speech (ms)** — `vadMinSpeechMs`, default `300`. Ignore very short sounds during automatic listening. Range: 100–2000.
|
||||||
|
- **Pause before sending (ms)** — `vadSilenceMs`, default `700`. Wait this long after speech before sending your request. Range: 200–3000.
|
||||||
|
- **Maximum recording (seconds)** — `vadMaxSpeechSeconds`, default `15`. Limit each recorded utterance, including pauses. Range: 3–120.
|
||||||
|
- **Echo protection after replies (ms)** — `playbackCooldownMs`, default `400`. Delay microphone processing after speech output ends. Range: 0–2000.
|
||||||
|
|
||||||
|
### 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: 1–100.
|
||||||
|
- **Grant duration (minutes)** — `computerGrantMinutes`, default `3`. Desktop access expires automatically. Range: 1–15.
|
||||||
|
|
||||||
|
### Desktop images
|
||||||
|
|
||||||
|
- **Screenshot maximum edge (pixels)** — `screenshotMaxEdge`, default `1280`. Larger screenshots preserve detail but take more memory. Range: 640–2560.
|
||||||
|
- **Screenshot quality (%)** — `screenshotQuality`, default `70`. Higher WebP quality preserves more text detail. Range: 30–95.
|
||||||
|
|
||||||
|
### 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: 1–30.
|
||||||
|
- **Shell commands per request** — `maxShellCalls`, default `1`. Requires restart. Commands still require normal permissions. Range: 1–10.
|
||||||
|
- **Tool rounds per request** — `maxToolRounds`, default `4`. Requires restart. Limits repeated tool use. Range: 1–20.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
"package": "bash packaging/build-release.sh",
|
"package": "bash packaging/build-release.sh",
|
||||||
"package:bare": "bash packaging/build-runtime-bundle.sh",
|
"package:bare": "bash packaging/build-runtime-bundle.sh",
|
||||||
"package:test": "bash packaging/test-package.sh",
|
"package:test": "bash packaging/test-package.sh",
|
||||||
"first-run": "bash packaging/first-run.sh",
|
|
||||||
"install:user": "bash packaging/install.sh",
|
"install:user": "bash packaging/install.sh",
|
||||||
"uninstall:user": "bash packaging/uninstall.sh",
|
"uninstall:user": "bash packaging/uninstall.sh",
|
||||||
"bare:smoke": "bash packaging/bare-launch.sh packaging/bare-smoke.js"
|
"bare:smoke": "bash packaging/bare-launch.sh packaging/bare-smoke.js"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Version: ${VERSION}
|
|||||||
Section: utils
|
Section: utils
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: ${ARCH}
|
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
|
Maintainer: JARVIS-QVAC maintainers
|
||||||
Description: Local GPU-backed GNOME voice assistant
|
Description: Local GPU-backed GNOME voice assistant
|
||||||
JARVIS-QVAC is a local-first voice assistant for Ubuntu GNOME.
|
JARVIS-QVAC is a local-first voice assistant for Ubuntu GNOME.
|
||||||
|
|||||||
@@ -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
@@ -185,8 +185,26 @@ activate_extension() {
|
|||||||
}
|
}
|
||||||
sed "s#__JARVIS_BARE__#${BARE_BIN}#" "${ROOT_DIR}/packaging/jarvisd.service" > "${HOME}/.config/systemd/user/jarvisd.service"
|
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"
|
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
|
if [[ -f "${CONFIG_DIR}/config.json" ]]; then
|
||||||
log "kept existing ${CONFIG_DIR}/config.json"
|
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
|
fi
|
||||||
if [[ ! -f "${CONFIG_DIR}/qvac.config.json" ]]; then
|
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"
|
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
|
fi
|
||||||
log "install complete; log file ${LOG_FILE}"
|
log "install complete; log file ${LOG_FILE}"
|
||||||
echo "Installed JARVIS-QVAC to ${APP_DIR}"
|
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
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=JARVIS-QVAC local voice assistant
|
Description=JARVIS-QVAC local voice assistant
|
||||||
After=pipewire.service wireplumber.service graphical-session.target
|
After=graphical-session.target pipewire.service pipewire-pulse.service wireplumber.service
|
||||||
Wants=pipewire.service
|
Wants=pipewire.service wireplumber.service
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
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=QVAC_CONFIG_PATH=%h/.config/jarvis/qvac.config.json
|
||||||
Environment=JARVIS_GPU_REQUIRED=1
|
Environment=JARVIS_GPU_REQUIRED=1
|
||||||
Environment=JARVIS_QVAC_OWNER=jarvisd
|
Environment=JARVIS_QVAC_OWNER=jarvisd
|
||||||
Restart=on-failure
|
Restart=always
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
WantedBy=graphical-session.target
|
WantedBy=graphical-session.target
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ fi
|
|||||||
gnome-extensions disable [email protected] 2>/dev/null || true
|
gnome-extensions disable [email protected] 2>/dev/null || true
|
||||||
systemctl --user disable --now jarvisd.service 2>/dev/null || true
|
systemctl --user disable --now jarvisd.service 2>/dev/null || true
|
||||||
rm -f "${HOME}/.config/systemd/user/jarvisd.service"
|
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]"
|
rm -rf "${HOME}/.local/share/jarvis-qvac" "${HOME}/.local/share/gnome-shell/extensions/[email protected]"
|
||||||
systemctl --user daemon-reload 2>/dev/null || true
|
systemctl --user daemon-reload 2>/dev/null || true
|
||||||
if [[ "${2:-}" == "--purge-data" ]]; then
|
if [[ "${2:-}" == "--purge-data" ]]; then
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ if [[ -n "${BUNDLE_NAME}" ]]; then
|
|||||||
test -n "${SOURCE_DIR}" || { echo "Bare bundle contained no application directory" >&2; exit 1; }
|
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; }
|
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"
|
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
|
||||||
fi
|
fi
|
||||||
if ! command -v npm >/dev/null || ! command -v node >/dev/null; then
|
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; }
|
test -x "$SOURCE_DIR/packaging/install.sh" || { echo "Downloaded ref has no installer" >&2; exit 1; }
|
||||||
|
|
||||||
echo "Installing into the current user account"
|
echo "Installing into the current user account"
|
||||||
exec bash "$SOURCE_DIR/packaging/install.sh" "$@"
|
bash "$SOURCE_DIR/packaging/install.sh" "$@"
|
||||||
|
|||||||
@@ -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');
|
|
||||||
@@ -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}`);
|
||||||
@@ -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');
|
||||||
@@ -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
@@ -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 os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { SKILLS } from './catalog.js';
|
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' });
|
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() {
|
async function desktopApps() {
|
||||||
const dirs = ['/usr/share/applications', path.join(os.homedir(), '.local/share/applications')];
|
const dirs = ['/usr/share/applications', path.join(os.homedir(), '.local/share/applications')];
|
||||||
const apps = [];
|
const apps = [];
|
||||||
@@ -54,19 +71,19 @@ export function createPhase2Tools({ cwd = process.cwd(), computer } = {}) {
|
|||||||
name: 'fs_search', permission: PERMISSIONS.read,
|
name: 'fs_search', permission: PERMISSIONS.read,
|
||||||
description: 'Search local file and directory names below the Jarvis workspace.',
|
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'] },
|
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,
|
name: 'fs_read', permission: PERMISSIONS.read,
|
||||||
description: 'Read a UTF-8 local text file below the Jarvis workspace, capped at 400 KiB.',
|
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'] },
|
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,
|
name: 'fs_write', permission: PERMISSIONS.write,
|
||||||
description: 'Write a local text file only after an explicit confirmation flag is supplied.',
|
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'] },
|
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,
|
name: 'memory_recall', permission: PERMISSIONS.read,
|
||||||
|
|||||||
@@ -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');
|
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/);
|
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(); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { VoiceStateMachine } from '../daemon/voice-state.js';
|
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(order, ['first', 'voice', 'media']);
|
||||||
assert.deepEqual(scheduler.status(), { running: 0, queued: [] });
|
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(); }
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 Shell from 'gi:\/\/Shell'/);
|
||||||
assert.match(extensionSource, /import Meta from 'gi:\/\/Meta'/);
|
assert.match(extensionSource, /import Meta from 'gi:\/\/Meta'/);
|
||||||
assert.match(extensionSource, /new PanelMenu.Button\(0.0, 'Jarvis QVAC', false\)/);
|
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, /\['Thinking'/);
|
||||||
assert.match(extensionSource, /\['ToolCall'/);
|
assert.match(extensionSource, /\['ToolCall'/);
|
||||||
assert.match(extensionSource, /_openPopup/);
|
assert.match(extensionSource, /_openPopup/);
|
||||||
assert.match(uiSource, /Open conversation/);
|
assert.match(uiSource, /Open conversation/);
|
||||||
assert.match(extensionSource, /PopupMenuSection/);
|
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, /_openSettings/);
|
||||||
assert.match(extensionSource, /PopupMenuItem\('Settings'\)/);
|
assert.match(extensionSource, /PopupMenuItem\('Settings'\)/);
|
||||||
assert.match(extensionSource, /PopupMenuItem\('Grant desktop'\)/);
|
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/);
|
assert.match(session.view.transcript.children[0].text, /still talking/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('OSD shows listening without opening a conversation panel', () => {
|
test('voice status lives in the panel chip, not a floating overlay', () => {
|
||||||
const { JarvisOsd, SessionPanel, timers } = harness();
|
const { JarvisOsd, SessionPanel, timers, chrome } = harness();
|
||||||
const osd = new JarvisOsd();
|
const osd = new JarvisOsd();
|
||||||
const session = new SessionPanel();
|
const session = new SessionPanel();
|
||||||
osd.attach();
|
const bar = { children: [], add_child(child) { this.children.push(child); } };
|
||||||
|
osd.attach(bar);
|
||||||
session.attach();
|
session.attach();
|
||||||
|
assert.equal(bar.children[0], osd.root);
|
||||||
osd.setState('LISTENING');
|
osd.setState('LISTENING');
|
||||||
assert.equal(osd.root.visible, true);
|
assert.equal(osd.root.visible, true);
|
||||||
assert.match(osd.label.text, /Listening/);
|
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);
|
assert.equal(osd.root.visible, false);
|
||||||
osd.setState('SPEAKING');
|
osd.setState('SPEAKING');
|
||||||
assert.equal(osd.root.visible, true);
|
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();
|
osd.showWake();
|
||||||
|
assert.match(osd.label.text, /Wake/);
|
||||||
assert.equal(timers.size, 1);
|
assert.equal(timers.size, 1);
|
||||||
osd.destroy();
|
osd.destroy();
|
||||||
assert.equal(timers.size, 0);
|
assert.equal(timers.size, 0);
|
||||||
|
assert.equal(chrome.includes(osd.root), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('computer-use chrome shows a target without a transcript', () => {
|
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]);
|
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 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 control = readFileSync(new URL('../apps/control-center/main.js', import.meta.url), 'utf8');
|
||||||
const keys = [...schema.matchAll(/<key name="([^"]+)"/g)].map((match) => match[1]);
|
assert.match(prefs, /fillSettingsWindow/);
|
||||||
assert.ok(keys.length >= 14);
|
assert.match(control, /fillSettingsWindow/);
|
||||||
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/);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+50
-7
@@ -105,7 +105,12 @@ exit 0
|
|||||||
assert.equal(await readFile(path.join(configDir, 'config.json'), 'utf8'), '{"wakePhrase":"hey jarvis","ttsEnabled":true}\n');
|
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');
|
const unit = await readFile(path.join(home, '.config/systemd/user/jarvisd.service'), 'utf8');
|
||||||
assert.match(unit, /bare-runtime-linux/);
|
assert.match(unit, /bare-runtime-linux/);
|
||||||
|
assert.match(unit, /WantedBy=default\.target/);
|
||||||
|
assert.match(unit, /Restart=always/);
|
||||||
assert.doesNotMatch(unit, /\/usr\/bin\/node/);
|
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');
|
const commands = await readFile(log, 'utf8');
|
||||||
assert.match(commands, /systemctl --user stop jarvisd\.service/);
|
assert.match(commands, /systemctl --user stop jarvisd\.service/);
|
||||||
assert.match(commands, /gnome-extensions enable jarvis@qvac\.local/);
|
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', () => {
|
test('install.sh seeds default config when missing and does not require first-run', async () => {
|
||||||
const source = readFileSync(new URL('../packaging/first-run.sh', import.meta.url), 'utf8');
|
const work = await mkdtemp(path.join(os.tmpdir(), 'jarvis-install-defaults-'));
|
||||||
assert.match(source, /Play a TTS preview now\?/);
|
const home = path.join(work, 'home');
|
||||||
assert.match(source, /TTS_ENABLED=true/);
|
const root = path.join(work, 'src');
|
||||||
assert.doesNotMatch(source, /Enable TTS preview\?/);
|
const bin = path.join(work, 'bin');
|
||||||
assert.doesNotMatch(source, /TTS_ENABLED=false/);
|
const machine = (await run('uname', ['-m'], process.env)).stdout.trim();
|
||||||
assert.match(source, /spoken replies remain enabled/);
|
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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
});
|
||||||
@@ -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]');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -151,6 +151,57 @@ test('QVAC numeric PCM arrays preserve signed 16-bit samples', () => {
|
|||||||
assert.deepEqual([...pcmS16le([256, -32768, 32767]).samples], [256, -32768, 32767]);
|
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', () => {
|
test('voice adapters load canonical ASR and TTS plugins', () => {
|
||||||
const source = readFileSync(new URL('../daemon/voice-adapters.js', import.meta.url), 'utf8');
|
const source = readFileSync(new URL('../daemon/voice-adapters.js', import.meta.url), 'utf8');
|
||||||
assert.match(source, /whispercpp-transcription/);
|
assert.match(source, /whispercpp-transcription/);
|
||||||
|
|||||||
+11
@@ -12,6 +12,7 @@ const MAX_DESC = 2000;
|
|||||||
const bySession = new Map();
|
const bySession = new Map();
|
||||||
const sessionOpts = new Map();
|
const sessionOpts = new Map();
|
||||||
const handlers = new Map();
|
const handlers = new Map();
|
||||||
|
const permissions = new Map();
|
||||||
|
|
||||||
function setReserved(names) {
|
function setReserved(names) {
|
||||||
reserved = new Set(names);
|
reserved = new Set(names);
|
||||||
@@ -83,6 +84,9 @@ function register(sessionId, tools) {
|
|||||||
throw new Error('too many custom tools (max ' + MAX_TOOLS + ')');
|
throw new Error('too many custom tools (max ' + MAX_TOOLS + ')');
|
||||||
}
|
}
|
||||||
map.set(schema.name, schema);
|
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') {
|
if (typeof t.execute === 'function') {
|
||||||
let h = handlers.get(sessionId);
|
let h = handlers.get(sessionId);
|
||||||
if (!h) {
|
if (!h) {
|
||||||
@@ -101,6 +105,7 @@ function unregister(sessionId, name) {
|
|||||||
if (map && name) map.delete(name);
|
if (map && name) map.delete(name);
|
||||||
const h = handlers.get(sessionId);
|
const h = handlers.get(sessionId);
|
||||||
if (h && name) h.delete(name);
|
if (h && name) h.delete(name);
|
||||||
|
permissions.get(sessionId)?.delete(name);
|
||||||
return list(sessionId);
|
return list(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +113,7 @@ function clear(sessionId) {
|
|||||||
bySession.delete(sessionId);
|
bySession.delete(sessionId);
|
||||||
sessionOpts.delete(sessionId);
|
sessionOpts.delete(sessionId);
|
||||||
handlers.delete(sessionId);
|
handlers.delete(sessionId);
|
||||||
|
permissions.delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function has(sessionId, name) {
|
function has(sessionId, name) {
|
||||||
@@ -120,11 +126,16 @@ function getHandler(sessionId, name) {
|
|||||||
return h && h.get(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) {
|
function defs(sessionId) {
|
||||||
return list(sessionId);
|
return list(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
needsPermission,
|
||||||
NAME_RE,
|
NAME_RE,
|
||||||
MAX_TOOLS,
|
MAX_TOOLS,
|
||||||
setReserved,
|
setReserved,
|
||||||
|
|||||||
Vendored
+1
-1
@@ -781,7 +781,7 @@ async function runTurn(ctx) {
|
|||||||
continue;
|
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;
|
let remembered = null;
|
||||||
try {
|
try {
|
||||||
remembered = permStore.resolve(name, args);
|
remembered = permStore.resolve(name, args);
|
||||||
|
|||||||
Reference in New Issue
Block a user