Updates
Rolling release / release (push) Successful in 6m40s

This commit is contained in:
2026-09-12 07:22:47 -04:00
parent e4546f8e95
commit e9040d110a
30 changed files with 1688 additions and 444 deletions
@@ -8,28 +8,13 @@ import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import Pango from 'gi://Pango';
import { installShellService } from './shell-dbus.js';
import { ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel, coerceText, safeText, shortError } from './ui.js';
const BUS = 'io.qvac.Jarvis';
const PATH = '/io/qvac/Jarvis';
const IFACE = 'io.qvac.Jarvis.Session';
const OVERLAY_WIDTH = 720;
const STATES = new Set(['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING']);
const coerceText = (value) => {
if (value == null) return '';
if (typeof value === 'string') return value === '[object Object]' ? '' : value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (typeof value === 'object') {
if (typeof value.text === 'string') return value.text;
if (typeof value.message === 'string') return value.message;
if (typeof value.deep_unpack === 'function') return coerceText(value.deep_unpack());
}
const fallback = String(value);
return fallback === '[object Object]' ? '' : fallback;
};
const safeText = (value) => coerceText(value).replace(/[<>]/g, '');
const shortError = (value) => safeText(value).split('\n')[0].replace(/\s+/g, ' ').slice(0, 140);
const GLYPHS = { ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' };
class JarvisProxy {
async connect() {
@@ -56,175 +41,133 @@ class JarvisProxy {
}
}
class ArcOverlay {
constructor() {
this.root = new St.BoxLayout({ style_class: 'jarvis-arc', vertical: true, reactive: true, can_focus: true, track_hover: true, x_expand: true });
this.root.accessible_name = 'Jarvis ARC voice assistant';
this.header = new St.BoxLayout({ style_class: 'jarvis-arc-header', x_expand: true });
this.title = new St.Label({ text: '◉ JARVIS', style_class: 'jarvis-title', x_align: Clutter.ActorAlign.START }); this.title.accessible_name = 'Jarvis status';
if (this.title.clutter_text) this.title.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this.status = new St.Label({ text: 'LOCAL', style_class: 'jarvis-local' }); this.status.accessible_name = 'Local model status';
if (this.status.clutter_text) this.status.clutter_text.ellipsize = Pango.EllipsizeMode.END;
this.header.add_child(this.title); this.header.add_child(new St.Widget({ x_expand: true })); this.header.add_child(this.status);
this.statusLine = new St.Label({ text: 'Super+Shift+J · Hold Talk to speak', style_class: 'jarvis-status-line' });
this.thinkingToggle = new St.Button({ label: 'Thinking', style_class: 'jarvis-thinking-toggle', visible: false, can_focus: true, reactive: true, x_align: Clutter.ActorAlign.START });
this.thinkingToggle.accessible_name = 'Show or hide Jarvis thinking';
this.thinkingToggle.connect('clicked', () => this.toggleThinking());
this.thinking = new St.Label({ text: '', style_class: 'jarvis-thinking', visible: false, x_expand: true, can_focus: true });
this.thinking.accessible_name = 'Jarvis thinking';
if (this.thinking.clutter_text) { this.thinking.clutter_text.line_wrap = true; this.thinking.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; }
this.notice = new St.Label({ text: '', style_class: 'jarvis-notice', visible: false, x_expand: true });
this.notice.accessible_name = 'Jarvis notice';
this.job = new St.Label({ text: '', style_class: 'jarvis-job', can_focus: true, visible: false }); this.job.accessible_name = 'Jarvis job progress';
this.target = new St.Label({ text: '', style_class: 'jarvis-target', can_focus: true, visible: false }); this.target.accessible_name = 'Computer use target';
this.cursor = new St.Label({ text: '', style_class: 'jarvis-agent-cursor', can_focus: false, visible: false }); this.cursor.accessible_name = 'Visible computer use cursor';
this.confirm = new St.BoxLayout({ style_class: 'jarvis-confirm', visible: false, x_expand: true });
this.confirmLabel = new St.Label({ text: '', style_class: 'jarvis-confirm-label', x_expand: true });
this.confirm.add_child(this.confirmLabel);
for (const [label, decision] of [['Allow', 'allow'], ['Deny', 'deny']]) {
const button = new St.Button({ label, style_class: 'jarvis-chip', reactive: true, can_focus: true });
button.connect('clicked', () => this._answerConfirm(decision));
this.confirm.add_child(button);
}
this.scroll = new St.ScrollView({ style_class: 'jarvis-transcript-scroll', overlay_scrollbars: true, x_expand: true });
try { this.scroll.hscrollbar_policy = St.PolicyType.NEVER; this.scroll.vscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
this.transcript = new St.BoxLayout({ style_class: 'jarvis-transcript', vertical: true, x_expand: true }); this.transcript.accessible_name = 'Conversation transcript';
if (typeof this.scroll.set_child === 'function') this.scroll.set_child(this.transcript); else this.scroll.add_child(this.transcript);
this.wave = new St.BoxLayout({ style_class: 'jarvis-wave', visible: false }); this.bars = [];
for (let i = 0; i < 48; i++) { const bar = new St.Widget({ style_class: 'jarvis-wave-bar', height: 4, y_align: Clutter.ActorAlign.CENTER }); this.wave.add_child(bar); this.bars.push(bar); }
this.chipScroll = new St.ScrollView({ style_class: 'jarvis-chip-scroll', overlay_scrollbars: true, x_expand: true });
try { this.chipScroll.vscrollbar_policy = St.PolicyType.NEVER; this.chipScroll.hscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
this.chips = new St.BoxLayout({ style_class: 'jarvis-chips' }); this.chips.accessible_name = 'Suggested actions';
if (typeof this.chipScroll.set_child === 'function') this.chipScroll.set_child(this.chips); else this.chipScroll.add_child(this.chips);
for (const mode of ['Chat', 'Files', 'Vision', 'Imagine', 'Compose', 'Translate', 'Dictate', 'Computer', 'Lab']) {
const chip = new St.Button({ label: mode, style_class: 'jarvis-chip', can_focus: true, reactive: true }); chip.accessible_name = `${mode} mode`;
chip.connect('clicked', () => this.onMode?.(mode.toLowerCase())); this.chips.add_child(chip);
}
this.root.add_child(this.header); this.root.add_child(this.statusLine); this.root.add_child(this.thinkingToggle); this.root.add_child(this.thinking); this.root.add_child(this.notice); this.root.add_child(this.confirm); this.root.add_child(this.job); this.root.add_child(this.target); this.root.add_child(this.cursor); this.root.add_child(this.wave); this.root.add_child(this.scroll); this.root.add_child(this.chipScroll);
this.controls = new St.BoxLayout({ style_class: 'jarvis-controls' });
this.talk = new St.Button({ label: 'Hold to talk', style_class: 'jarvis-chip jarvis-talk', reactive: true, can_focus: true });
this.talk.accessible_name = 'Hold Space or Enter to talk';
this.talk.connect('key-press-event', (_actor, event) => { if ([Clutter.KEY_space, Clutter.KEY_Return].includes(event.get_key_symbol())) { this.onTalk?.(true); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; });
this.talk.connect('key-release-event', (_actor, event) => { if ([Clutter.KEY_space, Clutter.KEY_Return].includes(event.get_key_symbol())) { this.onTalk?.(false); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; });
this.talk.connect('key-focus-out', () => this.onTalk?.(false));
this.talk.connect('leave-event', () => { this.onTalk?.(false); return Clutter.EVENT_PROPAGATE; });
this.talk.connect('button-press-event', () => { this.onTalk?.(true); return Clutter.EVENT_STOP; });
this.talk.connect('button-release-event', () => { this.onTalk?.(false); return Clutter.EVENT_STOP; });
this.controls.add_child(this.talk);
for (const [label, action] of [['Stop', () => this.onStop?.()], ['Reset context', () => this.onReset?.()], ['Minimize', () => this.minimize()], ['Close', () => this.hide()]]) {
const button = new St.Button({ label, style_class: 'jarvis-chip jarvis-chip-quiet', reactive: true, can_focus: true });
button.connect('clicked', action); this.controls.add_child(button);
}
this.entry = new St.Entry({ hint_text: 'Ask Jarvis…', can_focus: true, x_expand: true });
this.entry.clutter_text.connect('activate', () => { const text = this.entry.get_text().trim(); if (text) { this.onAsk?.(text); this.entry.set_text(''); } });
this.root.add_child(this.entry); this.root.add_child(this.controls);
this.reducedMotion = false;
this.streamingReply = false;
this.replyFinalized = false;
this.minimized = false;
this._state = 'ARMED';
}
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.halo = new St.Widget({ style_class: 'jarvis-halo', reactive: false }); Main.layoutManager.addChrome(this.halo, { affectsStruts: false, trackFullscreen: false }); this.halo.hide(); this.hide(); }
show(force = false) {
if (this.minimized && !force) return;
this.minimized = false;
const monitor = Main.layoutManager.primaryMonitor;
if (monitor) {
const width = Math.min(OVERLAY_WIDTH, monitor.width - 80);
this.root.set_width(width);
this.root.set_position(monitor.x + Math.max(0, Math.round((monitor.width - width) / 2)), monitor.y + 48);
this.scroll.set_height(Math.max(80, Math.min(220, monitor.height - 420)));
}
const wasVisible = this.root.visible;
this.root.visible = true;
this.wave.visible = this._state === 'LISTENING' || this._state === 'SPEAKING';
if (!wasVisible) this.entry.grab_key_focus();
}
_dismiss() { this.onTalk?.(false); this.root.visible = false; this.minimized = true; this.halo?.hide(); }
hide() { this._dismiss(); }
minimize() { this._dismiss(); }
toggle() { this.root.visible ? this.hide() : this.show(true); }
clear() { this.transcript.destroy_all_children(); this.thinking.text = ''; this.thinking.visible = false; this.thinkingToggle.visible = false; this.notice.visible = false; this.confirm.visible = false; this.streamingReply = false; this.replyFinalized = false; }
setNotice(text) { const body = shortError(text); this.notice.text = body; this.notice.visible = Boolean(body); }
_followConversation() {
const adjustment = this.scroll?.get_vadjustment?.() || this.scroll?.vadjustment;
if (!adjustment) return;
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE || GLib.PRIORITY_DEFAULT, () => {
try { adjustment.value = Math.max(0, adjustment.upper - adjustment.page_size); } catch {}
return GLib.SOURCE_REMOVE;
});
}
addRow(who, text) { const body = safeText(text); const row = new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${body}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true }); row.accessible_name = `${who === 'J' ? 'Jarvis' : 'You'}: ${body}`; if (row.clutter_text) { row.clutter_text.line_wrap = true; row.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; row.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; } this.transcript.add_child(row); if (this.transcript.get_n_children() > 100) this.transcript.get_first_child().destroy(); this._followConversation(); return row; }
token(text) { const chunk = safeText(text); if (!chunk) return; let row = this.transcript.get_last_child?.(); if (!this.streamingReply || !row || !String(row.style_class || '').includes('jarvis-row-jarvis') || String(row.style_class || '').includes('jarvis-row-tool')) { row = this.addRow('J', ''); this.streamingReply = true; this.replyFinalized = false; } row.text = `${row.text}${chunk}`; row.accessible_name = `Jarvis: ${row.text}`; this._followConversation(); }
updateThinking(text) { const chunk = safeText(text); if (!chunk) return; this.thinking.text = `${this.thinking.text || ''}${chunk}`; this.thinkingToggle.visible = true; this.thinkingToggle.label = 'Thinking ▾'; }
toggleThinking() { this.thinking.visible = !this.thinking.visible; this.thinkingToggle.label = this.thinking.visible ? 'Thinking ▾' : 'Thinking ▸'; }
finishThinking() { if (this.thinking.text) { this.thinkingToggle.visible = true; this.thinkingToggle.label = 'Thinking ▸'; this.thinking.visible = false; } }
addToolCall(json) { this.streamingReply = false; this.replyFinalized = false; try { const call = JSON.parse(json); this._addToolRow(`Using ${safeText(call.name || 'tool')}`); } catch { this._addToolRow(`Using ${safeText(json)}`); } }
addToolResult(json) { this.streamingReply = false; try { const result = JSON.parse(json); this._addToolRow(`${safeText(result.name || 'tool')} complete`); } catch { this._addToolRow('Tool complete'); } }
_addToolRow(text) { const row = this.addRow('J', text); row.style_class = `${row.style_class} jarvis-row-tool`; }
offerConfirm(tool, argsJson, pattern) {
let jobId = ''; let toolCallId = '';
try { const parsed = JSON.parse(argsJson); jobId = parsed.jobId || ''; toolCallId = parsed.toolCallId || ''; } catch {}
this._confirmJob = jobId; this._confirmCall = toolCallId;
this.confirmLabel.text = `Allow ${safeText(tool)}? ${safeText(pattern)}`.trim();
this.confirm.visible = true;
}
_answerConfirm(decision) { this.confirm.visible = false; this.onConfirm?.(this._confirmJob || '', this._confirmCall || '', decision); }
finalizeReply(text) {
this.finishThinking();
const spoken = safeText(text);
if (this.replyFinalized) return;
let row = this.transcript.get_last_child?.();
if (this.streamingReply && row && String(row.style_class || '').includes('jarvis-row-jarvis') && !String(row.style_class || '').includes('jarvis-row-tool')) {
const body = String(row.text || '').replace(/^J\s+/, '');
if (spoken && !body.trim()) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
this.streamingReply = false; this.replyFinalized = true; return;
}
if (spoken) this.addRow('J', spoken);
this.replyFinalized = true;
}
setConnectionStatus(kind) {
const labels = { local: 'LOCAL', offline: 'LOCAL · daemon unavailable', 'voice-unavailable': 'LOCAL · voice unavailable' };
this.status.text = labels[kind] || labels.local;
this.status.accessible_name = this.status.text;
}
setState(state) {
const value = STATES.has(state) ? state : 'ARMED';
this._state = value;
this.statusLine.text = `${value.toLowerCase()} · Super+Shift+J · Hold Talk to speak`;
this.title.text = `${value === 'SLEEPING' ? '' : '◉'} JARVIS`;
this.status.style_class = `jarvis-local jarvis-state-${value.toLowerCase()}`;
this.wave.visible = !this.minimized && (value === 'LISTENING' || value === 'SPEAKING');
if (value === 'LISTENING') this.showHalo();
}
level(rms) { if (this.minimized) return; this.wave.visible = true; const value = Math.max(0, Math.min(1, Number(rms) || 0)); for (let i = 0; i < this.bars.length; i++) this.bars[i].height = Math.max(4, Math.round(4 + value * 32 * (0.45 + Math.abs(Math.sin(i * 1.7)) * 0.55))); }
addChip(id, label, payload) { const chip = new St.Button({ label: safeText(label), style_class: 'jarvis-chip jarvis-chip-suggested', can_focus: true }); chip.accessible_name = `Suggested action: ${safeText(label)}`; chip.connect('clicked', () => this.onSuggestion?.(id, payload)); this.chips.add_child(chip); }
addStep(json) { try { const step = JSON.parse(json); this.addRow('J', `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`); } catch { this.addRow('J', json); } }
addJob(id, pct, label) { this.job.text = `${safeText(label)} · ${Math.round(Number(pct) * 100)}%`; this.job.visible = true; }
setTarget(json) { this.target.text = `${safeText(json)}`; this.cursor.text = '◎'; this.cursor.visible = true; this.target.visible = true; }
showHalo() { if (!this.halo || this.minimized || !this.root.visible) return; this.halo.show(); if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 400, () => { this._haloTimeout = 0; this.halo?.hide(); return GLib.SOURCE_REMOVE; }); }
destroy() { if (this._haloTimeout) GLib.Source.remove(this._haloTimeout); this._haloTimeout = 0; this.halo?.destroy(); this.halo = null; this.root.destroy(); }
}
export default class JarvisExtension extends Extension {
enable() {
this._theme = St.ThemeContext.get_for_stage(global.stage).get_theme(); this._stylesheet = Gio.File.new_for_path(`${this.path}/stylesheet.css`); try { this._theme.load_stylesheet(this._stylesheet); } catch (error) { log(`Jarvis stylesheet unavailable: ${error.message}`); }
this.settings = this.getSettings(); this.proxy = new JarvisProxy(); this.overlay = new ArcOverlay(); this.overlay.attach(); this._applyAccessibility();
this.settings = this.getSettings();
this.proxy = new JarvisProxy();
this.popup = new ConversationView({ compact: true });
this.osd = new JarvisOsd(); this.osd.attach();
this.cu = new ComputerUseChrome(); this.cu.attach();
this.session = new SessionPanel(); this.session.attach();
this._bindSurface(this.popup);
this._bindSurface(this.session.view);
this._applyAccessibility();
this._removeShellService = installShellService();
this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant';
this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle'; this._indicator.add_child(this._glyph);
Main.panel.addToStatusArea('jarvis-qvac', this._indicator, 0, 'right');
this._indicator.connect('button-press-event', (_actor, event) => { const button = event.get_button(); if (button === 2) { this._call('Arm'); return Clutter.EVENT_STOP; } if (button === 3) { this._buildMenu(); return Clutter.EVENT_STOP; } this.overlay.toggle(); return Clutter.EVENT_STOP; });
this.overlay.onTalk = (pressed) => { if (pressed) { this._call('PushToTalk', '(b)', [true]); this._call('Arm'); } else { this._call('PushToTalk', '(b)', [false]); } };
this.overlay.onStop = () => this._call('Cancel');
this.overlay.onReset = () => { this.overlay.clear(); this.overlay.setNotice('New conversation'); this._call('ResetContext'); };
this.overlay.onAsk = (text) => { this.overlay.addRow('U', text); this._call('Ask', '(s)', [text]); };
this.overlay.onMode = (mode) => this._call('SetMode', '(s)', [mode]);
this.overlay.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
this.overlay.onConfirm = (jobId, toolCallId, decision) => this._call('Confirm', '(sss)', [jobId, toolCallId, decision]);
this._keyName = 'hotkey'; try { Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => this.overlay.show(true)); } catch (error) { log(`Jarvis hotkey unavailable: ${error.message}`); }
this._settingsChanged = this.settings.connect('changed::accent-color', () => this._applyAccent()); this._applyAccent(); this._connectDaemon(); this.overlay.show(true);
try { this._lockChanged = Main.screenShield.connect('locked-changed', () => { if (Main.screenShield.locked) { this.overlay.hide(); this._call('ComputerRevoke'); } }); } catch {}
this._mountPopup();
this._indicator.connect('button-press-event', (_actor, event) => {
const button = event.get_button();
if (button === 2) { this._call('Arm'); return Clutter.EVENT_STOP; }
return Clutter.EVENT_PROPAGATE;
});
this._keyName = 'hotkey';
try {
Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => {
this._openPopup();
this._call('Arm');
});
} catch (error) { log(`Jarvis hotkey unavailable: ${error.message}`); }
this._settingsChanged = this.settings.connect('changed::accent-color', () => this._applyAccent());
this._styleChanged = this.settings.connect('changed::overlay-style', () => this._applyLayout());
this._applyAccent();
this._applyLayout();
this._connectDaemon();
try { this._lockChanged = Main.screenShield.connect('locked-changed', () => { if (Main.screenShield.locked) { this._closeShell(); this._call('ComputerRevoke'); } }); } catch {}
}
_bindSurface(view) {
view.onTalk = (pressed) => { if (pressed) { this._call('PushToTalk', '(b)', [true]); this._call('Arm'); } else { this._call('PushToTalk', '(b)', [false]); } };
view.onStop = () => this._call('Cancel');
view.onReset = () => { this._eachView((surface) => { surface.clear(); surface.setNotice('New conversation'); }); this._call('ResetContext'); };
view.onAsk = (text) => { this._eachView((surface) => surface.addRow('U', text)); this._call('Ask', '(s)', [text]); };
view.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
view.onConfirm = (jobId, toolCallId, decision) => this._call('Confirm', '(sss)', [jobId, toolCallId, decision]);
view.onConfirmShown = () => this._openPopup();
view.onExpand = () => this.session.show(true);
view.onSettings = () => this._openSettings();
view.onMode = (mode) => this._call('SetMode', '(s)', [mode]);
}
_eachView(fn) { if (this.popup) fn(this.popup); if (this.session?.view) fn(this.session.view); }
_mountPopup() {
const menu = this._indicator.menu;
try {
const section = new PopupMenu.PopupMenuSection();
section.actor?.add_style_class_name?.('jarvis-menu-item');
section.actor.add_child(this.popup.root);
menu.addMenuItem(section);
} catch {
menu.box.add_child(this.popup.root);
}
menu.actor?.add_style_class_name?.('jarvis-menu');
try {
menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
const grantItem = new PopupMenu.PopupMenuItem('Grant desktop');
grantItem.connect('activate', () => this._call('ComputerGrant', '(b)', [true]));
menu.addMenuItem(grantItem);
const revokeItem = new PopupMenu.PopupMenuItem('Revoke desktop');
revokeItem.connect('activate', () => this._call('ComputerRevoke'));
menu.addMenuItem(revokeItem);
const settingsItem = new PopupMenu.PopupMenuItem('Settings');
settingsItem.connect('activate', () => this._openSettings());
menu.addMenuItem(settingsItem);
} catch {}
this._menuState = menu.connect('open-state-changed', (_menu, open) => { if (!open) this.popup.endTalk(); });
}
_openPopup() { try { this._indicator.menu.open(); } catch {} }
_openSettings() {
const uuid = this.uuid;
try { this._indicator.menu.close(); } catch {}
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 120, () => {
this._launchPreferences(uuid);
return GLib.SOURCE_REMOVE;
});
}
_launchPreferences(uuid) {
try {
if (Main.extensionManager?.openExtensionPrefs) {
Main.extensionManager.openExtensionPrefs(uuid, '', {});
return;
}
} catch (error) { log(`Jarvis preferences manager: ${error.message}`); }
try {
if (typeof this.openPreferences === 'function') {
this.openPreferences();
return;
}
} catch (error) { log(`Jarvis preferences: ${error.message}`); }
Gio.DBus.session.call(
'org.gnome.Shell.Extensions',
'/org/gnome/Shell/Extensions',
'org.gnome.Shell.Extensions',
'OpenExtensionPrefs',
new GLib.Variant('(ssa{sv})', [uuid, '', {}]),
null,
Gio.DBusCallFlags.NONE,
-1,
null,
(_source, result) => {
try { Gio.DBus.session.call_finish(result); }
catch (error) {
log(`Jarvis preferences dbus: ${error.message}`);
try { Gio.Subprocess.new(['gnome-extensions', 'prefs', uuid], Gio.SubprocessFlags.NONE); }
catch (err) { this.popup?.setNotice(`Could not open settings: ${shortError(err.message)}`); }
}
},
);
}
_closeShell() {
try { this._indicator.menu.close(); } catch {}
this.popup.endTalk();
this.session.hide();
this.osd.hide();
this.cu.hide();
}
_applyLayout() {
const expanded = this.settings.get_string('overlay-style') === 'expanded';
if (expanded) this.session.show(true);
else this.session.hide();
}
async _connectDaemon() {
const proxy = this.proxy;
@@ -232,63 +175,96 @@ export default class JarvisExtension extends Extension {
await proxy.connect(); if (this.proxy !== proxy) return;
this._signals = [
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
['ContextReset', () => { this.overlay.clear(); this.overlay.setNotice('New conversation'); }],
['WakeHeard', () => {}],
['PartialTranscript', (text) => this.overlay.addRow('U', text)],
['Token', (text) => this.overlay.token(text)],
['Thinking', (text) => this.overlay.updateThinking(text)],
['ToolCall', (json) => this.overlay.addToolCall(json)],
['ToolResult', (json) => this.overlay.addToolResult(json)],
['Reply', (text) => this.overlay.finalizeReply(text)],
['SpeakingLevel', (rms) => this.overlay.level(rms)],
['ListeningLevel', (rms) => this.overlay.level(rms)],
['ChipOffered', (id, label, payload) => this.overlay.addChip(id, label, payload)],
['JobProgress', (id, pct, label) => this.overlay.addJob(id, pct, label)],
['ComputerStep', (json) => this.overlay.addStep(json)],
['ComputerHighlight', (json) => this.overlay.setTarget(json)],
['ConfirmationRequired', (tool, args, pattern) => this.overlay.offerConfirm(tool, args, pattern)],
['Error', (code, message) => { this.overlay.setNotice(message); this.overlay.finishThinking(); if (coerceText(code) === 'VOICE_UNAVAILABLE') this.overlay.setConnectionStatus('voice-unavailable'); }],
['ContextReset', () => { this._eachView((view) => { view.clear(); view.setNotice('New conversation'); }); }],
['WakeHeard', () => this.osd.showWake()],
['PartialTranscript', (text) => this._eachView((view) => view.addRow('U', text))],
['Token', (text) => this._eachView((view) => view.token(text))],
['Thinking', (text) => this._eachView((view) => view.updateThinking(text))],
['ToolCall', (json) => this._eachView((view) => view.addToolCall(json))],
['ToolResult', (json) => this._eachView((view) => view.addToolResult(json))],
['Reply', (text) => this._eachView((view) => view.finalizeReply(text))],
['SpeakingLevel', () => {}],
['ListeningLevel', () => {}],
['ChipOffered', (id, label, payload) => this._eachView((view) => view.addChip(id, label, payload))],
['JobProgress', (id, pct, label) => this.cu.addJob(id, pct, label)],
['ComputerStep', (json) => { this.cu.addStep(json); this._eachView((view) => view.addStep(json)); }],
['ComputerHighlight', (json) => this.cu.setTarget(json)],
['ConfirmationRequired', (tool, args, pattern) => { this._eachView((view) => view.offerConfirm(tool, args, pattern)); this._openPopup(); }],
['Error', (code, message) => { this._eachView((view) => { view.setNotice(message); view.finishThinking(); }); if (coerceText(code) === 'VOICE_UNAVAILABLE') this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }],
].map(([name, handler]) => proxy.on(name, handler));
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
await this._syncDaemon();
} catch (error) { if (this.proxy !== proxy) return; this.overlay?.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}`); }
}
async _syncDaemon() {
if (!this.proxy) return;
if (!this.proxy.owned()) { this.overlay?.setConnectionStatus('offline'); return; }
if (!this.proxy.owned()) { this._eachView((view) => view.setConnectionStatus('offline')); return; }
try {
const result = await this.proxy.call('GetState'); if (!this.overlay) return;
const result = await this.proxy.call('GetState'); if (!this.popup) return;
this._setState(result.deep_unpack()[0]);
await this._refreshVoiceStatus();
} catch (error) { this.overlay?.setConnectionStatus('offline'); log(`Jarvis daemon unavailable: ${error.message}`); }
} catch (error) { this._eachView((view) => view.setConnectionStatus('offline')); log(`Jarvis daemon unavailable: ${error.message}`); }
}
async _refreshVoiceStatus() {
if (!this.proxy?.owned?.() || !this.overlay) return;
if (!this.proxy?.owned?.() || !this.popup) return;
try {
const runtime = await this.proxy.call('GetRuntimeStatus');
if (!this.overlay) return;
if (!this.popup) return;
const status = JSON.parse(runtime.deep_unpack()[0] || '{}');
const voice = status.voice;
this.overlay.setConnectionStatus(voice ? 'local' : 'voice-unavailable');
this._eachView((view) => view.setConnectionStatus(voice ? 'local' : 'voice-unavailable'));
if (voice) {
const input = voice.asr && voice.capture;
this.overlay.status.text = `${voice.tts ? 'SPEECH ON' : 'SPEECH OFF'} · ${input ? (voice.wake ? 'WAKE ON' : 'HOLD TALK') : 'MIC UNAVAILABLE'}`;
this.overlay.status.accessible_name = this.overlay.status.text;
this.overlay.talk.reactive = Boolean(input); this.overlay.talk.can_focus = Boolean(input);
this.overlay.talk.label = input ? 'Hold to talk' : 'Mic unavailable';
this._eachView((view) => view.setVoiceStatus({ tts: voice.tts, input, wake: voice.wake }));
}
} catch { this.overlay?.setConnectionStatus('voice-unavailable'); }
} catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }
}
_call(name, signature, value) {
this.proxy.call(name, signature, value).catch((error) => {
log(`Jarvis ${name}: ${error.message}`);
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
this.overlay?.setNotice(fallback);
this._eachView((view) => view.setNotice(fallback));
});
}
_setState(state) { const value = safeText(state); this._glyph.text = ({ ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' })[value] || '◯'; this._glyph.text += ' Jarvis'; this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`; this.overlay.setState(value); }
_applyAccent() { this.overlay.root.set_style(`--jarvis-accent: ${this.settings.get_string('accent-color')};`); }
_applyAccessibility() { try { const desktop = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' }); this.overlay.reducedMotion = desktop.list_keys().includes('enable-animations') && !desktop.get_boolean('enable-animations'); const theme = desktop.list_keys().includes('gtk-theme') ? desktop.get_string('gtk-theme') : ''; if (/high.?contrast/i.test(theme)) this.overlay.root.add_style_class_name('jarvis-high-contrast'); } catch {} this.overlay.root.connect('key-press-event', (_actor, event) => { if (event.get_key_symbol() === Clutter.KEY_Escape) { this._call('Cancel'); this.overlay.hide(); return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; }); }
_buildMenu() { const menu = this._indicator.menu; menu.removeAll(); for (const [label, action] of [['Open ARC', () => this.overlay.show(true)], ['Talk', () => this._call('Arm')], ['Stop', () => this._call('Cancel')], ['Reset context', () => this._call('ResetContext')], ['Privacy mode', () => this._call('Sleep')], ['Settings', () => this.openPreferences()]]) { const item = new PopupMenu.PopupMenuItem(label); item.connect('activate', action); menu.addMenuItem(item); } menu.open(); }
disable() { this._removeShellService?.(); try { Main.screenShield.disconnect(this._lockChanged); } catch {} try { Main.wm.removeKeybinding(this._keyName); } catch {} if (this._settingsChanged) this.settings.disconnect(this._settingsChanged); this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id)); this.proxy?.close(); this.overlay?.destroy(); this._indicator?.destroy(); if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} } this.overlay = this._indicator = this._glyph = this.proxy = null; }
_setState(state) {
const value = safeText(state);
this._glyph.text = `${GLYPHS[value] || ''} Jarvis`;
this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`;
this._eachView((view) => view.setState(value));
this.osd.setState(value);
}
_applyAccent() {
const style = `--jarvis-accent: ${this.settings.get_string('accent-color')};`;
this.popup.root.set_style(style);
this.session.root.set_style(style);
this.osd.root.set_style(style);
this.cu.root.set_style(style);
}
_applyAccessibility() {
try {
const desktop = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
this.popup.reducedMotion = desktop.list_keys().includes('enable-animations') && !desktop.get_boolean('enable-animations');
const theme = desktop.list_keys().includes('gtk-theme') ? desktop.get_string('gtk-theme') : '';
if (/high.?contrast/i.test(theme)) {
this.popup.root.add_style_class_name('jarvis-high-contrast');
this.session.root.add_style_class_name('jarvis-high-contrast');
}
} catch {}
}
disable() {
this._removeShellService?.();
try { Main.screenShield.disconnect(this._lockChanged); } catch {}
try { Main.wm.removeKeybinding(this._keyName); } catch {}
if (this._settingsChanged) this.settings.disconnect(this._settingsChanged);
if (this._styleChanged) this.settings.disconnect(this._styleChanged);
if (this._menuState) try { this._indicator.menu.disconnect(this._menuState); } catch {}
this._signals?.forEach((id) => this.proxy?.proxy?.disconnect(id));
this.proxy?.close();
this.osd?.destroy(); this.cu?.destroy(); this.session?.destroy(); this.popup?.destroy();
this._indicator?.destroy();
if (this._theme && this._stylesheet) { try { this._theme.unload_stylesheet(this._stylesheet); } catch {} }
this.popup = this.session = this.osd = this.cu = this._indicator = this._glyph = this.proxy = null;
}
}
export { JarvisProxy, ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel };
@@ -1,7 +1,7 @@
{
"uuid": "[email protected]",
"name": "Jarvis QVAC",
"description": "Local-first QVAC voice assistant HUD for GNOME",
"description": "Local-first QVAC voice assistant in the GNOME top bar",
"shell-version": ["46", "47", "48", "49", "50"],
"version": 2,
"session-modes": ["user"],
@@ -5,8 +5,11 @@ 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 = ['arc', 'compact'];
const OVERLAY_STYLES = ['tray', 'expanded'];
const COMPUTER_MODES = ['off', 'observe', 'act'];
const MODEL_PROFILES = ['laptop-8gb', 'laptop-16gb', 'desktop-gpu'];
@@ -61,6 +64,75 @@ function comboRow(settings, title, subtitle, key, values) {
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 {
fillPreferencesWindow(window) {
window.set_title('Jarvis QVAC');
@@ -79,13 +151,14 @@ export default class JarvisPreferences extends ExtensionPreferences {
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, 'Overlay style', 'Layout of the on-screen HUD', 'overlay-style', OVERLAY_STYLES));
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 after a grant', 'computer-use-mode', COMPUTER_MODES));
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);
@@ -6,7 +6,7 @@
<key name="voice-id" type="s"><default>''</default></key>
<key name="language" type="s"><default>'en-US'</default></key>
<key name="privacy-mode" type="s"><default>'full-listen-after-wake'</default></key>
<key name="overlay-style" type="s"><default>'arc'</default></key>
<key name="overlay-style" type="s"><default>'tray'</default></key>
<key name="confirm-destructive" type="b"><default>true</default></key>
<key name="computer-use-mode" type="s"><default>'off'</default></key>
<key name="computer-use-legacy-input" type="b"><default>false</default></key>
@@ -1,35 +1,41 @@
.jarvis-panel-glyph { color: #F4B942; font-size: 16px; }
.jarvis-arc { width: 720px; padding: 18px 20px; margin-top: 0; margin-left: 0; margin-right: 0; spacing: 8px; border-radius: 22px; background-color: rgba(11, 14, 20, .92); border: 1px solid rgba(244, 185, 66, .42); color: #f6f7fb; box-shadow: 0 12px 40px rgba(0, 0, 0, .45); }
.jarvis-arc-header { spacing: 8px; }
.jarvis-title { font-weight: bold; letter-spacing: 1px; font-size: 18px; }
.jarvis-panel-glyph { color: var(--jarvis-accent, #F4B942); font-size: 16px; }
.jarvis-menu { max-width: 360px; }
.jarvis-menu-item { padding: 0; }
.jarvis-popup { width: 320px; padding: 12px 14px; spacing: 8px; color: #f6f7fb; }
.jarvis-session { width: 420px; padding: 14px 16px; spacing: 8px; border-radius: 16px; background-color: rgba(11, 14, 20, .94); border: 1px solid rgba(244, 185, 66, .42); color: #f6f7fb; box-shadow: 0 12px 40px rgba(0, 0, 0, .45); }
.jarvis-popup-header { spacing: 8px; }
.jarvis-title { font-weight: bold; letter-spacing: 0.4px; font-size: 14px; }
.jarvis-local { color: var(--jarvis-accent, #F4B942); font-size: 11px; }
.jarvis-status-line { color: #aeb6c8; font-size: 12px; }
.jarvis-thinking-toggle { color: #9aa8c0; font-size: 11px; padding: 2px 8px; border-radius: 8px; background-color: transparent; }
.jarvis-thinking-toggle:hover, .jarvis-thinking-toggle:focus { color: #f6f7fb; background-color: rgba(79, 210, 255, .16); }
.jarvis-thinking { color: #aeb6c8; font-size: 12px; padding: 6px 10px; border-left: 2px solid #4FD2FF; background-color: rgba(79, 210, 255, .06); border-radius: 8px; }
.jarvis-notice { color: #f4b942; font-size: 12px; }
.jarvis-confirm { spacing: 8px; padding: 6px 0; }
.jarvis-confirm { spacing: 8px; padding: 4px 0; }
.jarvis-confirm-actions { spacing: 6px; }
.jarvis-confirm-label { color: #f6f7fb; font-size: 13px; }
.jarvis-job, .jarvis-target { color: #4FD2FF; font-size: 12px; }
.jarvis-agent-cursor { color: var(--jarvis-accent, #F4B942); font-size: 22px; }
.jarvis-halo { border-top: 2px solid #F4B942; margin: 12px; }
.jarvis-high-contrast { border-width: 2px; background-color: #000; }
.jarvis-wave { height: 36px; spacing: 3px; }
.jarvis-wave-bar { width: 7px; background-color: var(--jarvis-accent, #F4B942); border-radius: 4px; }
.jarvis-transcript-scroll { height: 220px; }
.jarvis-popup-scroll { height: 140px; }
.jarvis-session-scroll { height: 220px; }
.jarvis-transcript { spacing: 6px; }
.jarvis-row { padding: 8px 12px; border-radius: 8px; font-size: 14px; }
.jarvis-row { padding: 6px 10px; border-radius: 8px; font-size: 13px; }
.jarvis-row-user { border-left: 2px solid var(--jarvis-accent, #F4B942); background-color: rgba(244, 185, 66, .08); }
.jarvis-row-jarvis { border-right: 2px solid #4FD2FF; background-color: rgba(79, 210, 255, .05); }
.jarvis-row-tool { background-color: transparent; color: #9aa8c0; font-size: 12px; padding: 4px 8px; }
.jarvis-chip-scroll { height: 36px; }
.jarvis-row-tool { background-color: transparent; color: #9aa8c0; font-size: 12px; padding: 2px 6px; }
.jarvis-chip-scroll { height: 32px; }
.jarvis-chips { spacing: 6px; }
.jarvis-chip { padding: 5px 9px; border-radius: 999px; background-color: rgba(255, 255, 255, .08); }
.jarvis-chip:hover, .jarvis-chip:focus { background-color: rgba(244, 185, 66, .25); }
.jarvis-chip-quiet { background-color: transparent; color: #aeb6c8; padding: 5px 8px; }
.jarvis-chip-quiet:hover, .jarvis-chip-quiet:focus { color: #f6f7fb; background-color: rgba(255, 255, 255, .08); }
.jarvis-talk { background-color: #F4B942; color: #16191f; font-weight: bold; padding: 9px 18px; }
.jarvis-talk { background-color: #F4B942; color: #16191f; font-weight: bold; padding: 8px 14px; }
.jarvis-talk:active { background-color: #ffe09a; }
.jarvis-controls { spacing: 8px; }
.jarvis-arc StEntry { border-radius: 12px; padding: 10px 12px; background-color: rgba(255, 255, 255, .06); color: #f6f7fb; border: 1px solid rgba(255, 255, 255, .15); }
.jarvis-arc StEntry:focus { border-color: #F4B942; }
.jarvis-controls { spacing: 6px; }
.jarvis-settings { font-size: 12px; padding: 4px 8px; }
.jarvis-popup StEntry, .jarvis-session StEntry { border-radius: 10px; padding: 8px 10px; background-color: rgba(255, 255, 255, .06); color: #f6f7fb; border: 1px solid rgba(255, 255, 255, .15); }
.jarvis-popup StEntry:focus, .jarvis-session StEntry:focus { border-color: #F4B942; }
.jarvis-osd { padding: 8px 16px; border-radius: 999px; background-color: rgba(11, 14, 20, .9); border: 1px solid rgba(244, 185, 66, .45); }
.jarvis-osd-label { color: #f6f7fb; font-size: 13px; font-weight: bold; }
.jarvis-cu { padding: 8px 12px; spacing: 4px; border-radius: 12px; background-color: rgba(11, 14, 20, .82); border: 1px solid rgba(79, 210, 255, .4); }
.jarvis-job, .jarvis-target, .jarvis-cu-step { color: #4FD2FF; font-size: 12px; }
.jarvis-agent-cursor { color: var(--jarvis-accent, #F4B942); font-size: 22px; }
.jarvis-high-contrast { border-width: 2px; background-color: #000; }
@@ -0,0 +1,346 @@
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import GLib from 'gi://GLib';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import Pango from 'gi://Pango';
export const STATES = new Set(['ARMED', 'LISTENING', 'THINKING', 'SPEAKING', 'SLEEPING']);
export const POPUP_ROWS = 8;
export const SESSION_WIDTH = 420;
export const coerceText = (value) => {
if (value == null) return '';
if (typeof value === 'string') return value === '[object Object]' ? '' : value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (typeof value === 'object') {
if (typeof value.text === 'string') return value.text;
if (typeof value.message === 'string') return value.message;
if (typeof value.deep_unpack === 'function') return coerceText(value.deep_unpack());
}
const fallback = String(value);
return fallback === '[object Object]' ? '' : fallback;
};
export const safeText = (value) => coerceText(value).replace(/[<>]/g, '');
export const shortError = (value) => safeText(value).split('\n')[0].replace(/\s+/g, ' ').slice(0, 140);
export const previewToolResult = (value) => {
const text = safeText(value).replace(/\s+/g, ' ').trim();
if (!text || text === '(no output)') return '';
return text.slice(0, 160);
};
function wrapLabel(label) {
if (label.clutter_text) {
label.clutter_text.line_wrap = true;
label.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
label.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
}
return label;
}
export class ConversationView {
constructor({ compact = true, maxRows = compact ? POPUP_ROWS : 40 } = {}) {
this.compact = compact;
this.maxRows = maxRows;
this.root = new St.BoxLayout({ style_class: compact ? 'jarvis-popup' : 'jarvis-session', vertical: true, reactive: true, can_focus: true, x_expand: true });
this.root.accessible_name = compact ? 'Jarvis voice assistant' : 'Jarvis conversation';
this.header = new St.BoxLayout({ style_class: 'jarvis-popup-header', x_expand: true });
this.title = new St.Label({ text: 'Jarvis', style_class: 'jarvis-title', x_align: Clutter.ActorAlign.START });
this.title.accessible_name = 'Jarvis status';
this.status = new St.Label({ text: 'LOCAL', style_class: 'jarvis-local' });
this.status.accessible_name = 'Local model status';
if (this.status.clutter_text) this.status.clutter_text.ellipsize = Pango.EllipsizeMode.END;
this.header.add_child(this.title);
this.header.add_child(new St.Widget({ x_expand: true }));
this.header.add_child(this.status);
this.settings = new St.Button({ label: 'Settings', style_class: 'jarvis-chip jarvis-chip-quiet jarvis-settings', reactive: true, can_focus: true });
this.settings.accessible_name = 'Open Jarvis settings';
this._bindChip(this.settings, () => this.onSettings?.());
this.header.add_child(this.settings);
this.statusLine = new St.Label({ text: 'armed · Hold Talk to speak', style_class: 'jarvis-status-line' });
this.thinkingToggle = new St.Button({ label: 'Thinking', style_class: 'jarvis-thinking-toggle', visible: false, can_focus: true, reactive: true, x_align: Clutter.ActorAlign.START });
this.thinkingToggle.accessible_name = 'Show or hide Jarvis thinking';
this.thinkingToggle.connect('clicked', () => this.toggleThinking());
this.thinking = wrapLabel(new St.Label({ text: '', style_class: 'jarvis-thinking', visible: false, x_expand: true, can_focus: true }));
this.thinking.accessible_name = 'Jarvis thinking';
this.notice = new St.Label({ text: '', style_class: 'jarvis-notice', visible: false, x_expand: true });
this.notice.accessible_name = 'Jarvis notice';
this.confirm = new St.BoxLayout({ style_class: 'jarvis-confirm', visible: false, x_expand: true, vertical: compact });
this.confirmLabel = wrapLabel(new St.Label({ text: '', style_class: 'jarvis-confirm-label', x_expand: true }));
this.confirm.add_child(this.confirmLabel);
this.confirmButtons = new St.BoxLayout({ style_class: 'jarvis-confirm-actions' });
for (const [label, decision] of [['Allow', 'allow'], ['Always allow', 'always'], ['Deny', 'deny']]) {
const button = new St.Button({ label, style_class: 'jarvis-chip', reactive: true, can_focus: true });
this._bindChip(button, () => this._answerConfirm(decision));
this.confirmButtons.add_child(button);
}
this.confirm.add_child(this.confirmButtons);
this.scroll = new St.ScrollView({ style_class: compact ? 'jarvis-popup-scroll' : 'jarvis-session-scroll', overlay_scrollbars: true, x_expand: true });
try { this.scroll.hscrollbar_policy = St.PolicyType.NEVER; this.scroll.vscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
this.transcript = new St.BoxLayout({ style_class: 'jarvis-transcript', vertical: true, x_expand: true });
this.transcript.accessible_name = 'Conversation transcript';
if (typeof this.scroll.set_child === 'function') this.scroll.set_child(this.transcript); else this.scroll.add_child(this.transcript);
this.chipScroll = new St.ScrollView({ style_class: 'jarvis-chip-scroll', overlay_scrollbars: true, x_expand: true, visible: false });
try { this.chipScroll.vscrollbar_policy = St.PolicyType.NEVER; this.chipScroll.hscrollbar_policy = St.PolicyType.AUTOMATIC; } catch {}
this.chips = new St.BoxLayout({ style_class: 'jarvis-chips' });
this.chips.accessible_name = 'Suggested actions';
if (typeof this.chipScroll.set_child === 'function') this.chipScroll.set_child(this.chips); else this.chipScroll.add_child(this.chips);
this.entry = new St.Entry({ hint_text: 'Ask Jarvis…', can_focus: true, x_expand: true });
this.entry.clutter_text.connect('activate', () => { const text = this.entry.get_text().trim(); if (text) { this.onAsk?.(text); this.entry.set_text(''); } });
this.controls = new St.BoxLayout({ style_class: 'jarvis-controls' });
this.talk = new St.Button({ label: 'Hold to talk', style_class: 'jarvis-chip jarvis-talk', reactive: true, can_focus: true });
this.talk.accessible_name = 'Hold to talk';
this.talk.connect('notify::pressed', () => this.onTalk?.(Boolean(this.talk.pressed)));
this.talk.connect('button-press-event', () => { this.onTalk?.(true); return Clutter.EVENT_PROPAGATE; });
this.talk.connect('button-release-event', () => { this.onTalk?.(false); return Clutter.EVENT_PROPAGATE; });
this.talk.connect('leave-event', () => { this.onTalk?.(false); return Clutter.EVENT_PROPAGATE; });
this.talk.connect('key-focus-out', () => this.onTalk?.(false));
this.controls.add_child(this.talk);
this.stop = new St.Button({ label: 'Stop', style_class: 'jarvis-chip jarvis-chip-quiet', reactive: true, can_focus: true });
this._bindChip(this.stop, () => this.onStop?.());
this.reset = new St.Button({ label: 'Reset', style_class: 'jarvis-chip jarvis-chip-quiet', reactive: true, can_focus: true });
this._bindChip(this.reset, () => this.onReset?.());
this.controls.add_child(this.stop);
this.controls.add_child(this.reset);
if (compact) {
this.expand = new St.Button({ label: 'Open', style_class: 'jarvis-chip jarvis-chip-quiet', reactive: true, can_focus: true });
this.expand.accessible_name = 'Open conversation';
this._bindChip(this.expand, () => this.onExpand?.());
this.controls.add_child(this.expand);
} else {
this.close = new St.Button({ label: 'Close', style_class: 'jarvis-chip jarvis-chip-quiet', reactive: true, can_focus: true });
this._bindChip(this.close, () => this.hide());
this.controls.add_child(this.close);
}
this.root.add_child(this.header);
this.root.add_child(this.statusLine);
this.root.add_child(this.thinkingToggle);
this.root.add_child(this.thinking);
this.root.add_child(this.notice);
this.root.add_child(this.confirm);
this.root.add_child(this.scroll);
this.root.add_child(this.chipScroll);
this.root.add_child(this.entry);
this.root.add_child(this.controls);
this.streamingReply = false;
this.replyFinalized = false;
this._state = 'ARMED';
this.reducedMotion = false;
}
_bindChip(button, action) {
button.connect('clicked', () => action?.());
}
clear() {
this.transcript.destroy_all_children();
this.thinking.text = '';
this.thinking.visible = false;
this.thinkingToggle.visible = false;
this.notice.visible = false;
this.confirm.visible = false;
this.chipScroll.visible = false;
this.chips.destroy_all_children();
this.streamingReply = false;
this.replyFinalized = false;
}
setNotice(text) { const body = shortError(text); this.notice.text = body; this.notice.visible = Boolean(body); }
_followConversation() {
const adjustment = this.scroll?.get_vadjustment?.() || this.scroll?.vadjustment;
if (!adjustment) return;
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE || GLib.PRIORITY_DEFAULT, () => {
try { adjustment.value = Math.max(0, adjustment.upper - adjustment.page_size); } catch {}
return GLib.SOURCE_REMOVE;
});
}
addRow(who, text) {
const body = safeText(text);
const row = wrapLabel(new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${body}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true }));
row.accessible_name = `${who === 'J' ? 'Jarvis' : 'You'}: ${body}`;
this.transcript.add_child(row);
while (this.transcript.get_n_children() > this.maxRows) {
const first = this.transcript.get_first_child();
if (!first) break;
if (typeof this.transcript.remove_child === 'function') this.transcript.remove_child(first);
else first.destroy();
}
this._followConversation();
return row;
}
token(text) {
const chunk = safeText(text);
if (!chunk) return;
if (!this.streamingReply && !chunk.trim()) return;
let row = this.transcript.get_last_child?.();
if (!this.streamingReply || !row || !String(row.style_class || '').includes('jarvis-row-jarvis') || String(row.style_class || '').includes('jarvis-row-tool')) {
row = this.addRow('J', '');
this.streamingReply = true;
this.replyFinalized = false;
}
row.text = `${row.text}${chunk}`;
row.accessible_name = `Jarvis: ${row.text}`;
this._followConversation();
}
updateThinking(text) {
const chunk = safeText(text);
if (!chunk) return;
this.thinking.text = `${this.thinking.text || ''}${chunk}`;
this.thinkingToggle.visible = true;
this.thinkingToggle.label = 'Thinking ▾';
}
toggleThinking() { this.thinking.visible = !this.thinking.visible; this.thinkingToggle.label = this.thinking.visible ? 'Thinking ▾' : 'Thinking ▸'; }
finishThinking() { if (this.thinking.text) { this.thinkingToggle.visible = true; this.thinkingToggle.label = 'Thinking ▸'; this.thinking.visible = false; } }
addToolCall(json) { this.streamingReply = false; this.replyFinalized = false; try { const call = JSON.parse(json); this._addToolRow(`Using ${safeText(call.name || 'tool')}`); } catch { this._addToolRow(`Using ${safeText(json)}`); } }
addToolResult(json) { this.streamingReply = false; try { const result = JSON.parse(json); const name = safeText(result.name || 'tool'); const preview = previewToolResult(result.result); this._addToolRow(preview ? `${name}: ${preview}` : `${name} complete`); } catch { this._addToolRow('Tool complete'); } }
_addToolRow(text) { const row = this.addRow('J', text); row.style_class = `${row.style_class} jarvis-row-tool`; }
offerConfirm(tool, argsJson, pattern) {
let jobId = ''; let toolCallId = ''; let detail = safeText(pattern);
try {
const parsed = JSON.parse(argsJson);
jobId = parsed.jobId || '';
toolCallId = parsed.toolCallId || '';
const args = parsed.args || {};
const command = args.command || args.path || args.file || args.url || '';
if (command) detail = String(command).replace(/\s+/g, ' ').slice(0, 80);
} catch {}
this._confirmJob = jobId; this._confirmCall = toolCallId;
this.confirmLabel.text = `Allow ${safeText(tool)}? ${detail}`.trim();
this.confirm.visible = true;
this.onConfirmShown?.();
}
_answerConfirm(decision) { this.confirm.visible = false; this.onConfirm?.(this._confirmJob || '', this._confirmCall || '', decision); }
finalizeReply(text) {
this.finishThinking();
const spoken = safeText(text);
if (this.replyFinalized) return;
let row = this.transcript.get_last_child?.();
if (this.streamingReply && row && String(row.style_class || '').includes('jarvis-row-jarvis') && !String(row.style_class || '').includes('jarvis-row-tool')) {
const body = String(row.text || '').replace(/^J\s+/, '');
if (spoken && !body.trim()) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; }
this.streamingReply = false; this.replyFinalized = true; return;
}
if (spoken) this.addRow('J', spoken);
this.replyFinalized = true;
}
setConnectionStatus(kind) {
const labels = { local: 'LOCAL', offline: 'LOCAL · daemon unavailable', 'voice-unavailable': 'LOCAL · voice unavailable' };
this.status.text = labels[kind] || labels.local;
this.status.accessible_name = this.status.text;
}
setVoiceStatus({ tts, input, wake } = {}) {
this.status.text = `${tts ? 'SPEECH ON' : 'SPEECH OFF'} · ${input ? (wake ? 'WAKE ON' : 'HOLD TALK') : 'MIC UNAVAILABLE'}`;
this.status.accessible_name = this.status.text;
this.talk.reactive = Boolean(input);
this.talk.can_focus = Boolean(input);
this.talk.label = input ? 'Hold to talk' : 'Mic unavailable';
}
setState(state) {
const value = STATES.has(state) ? state : 'ARMED';
this._state = value;
this.statusLine.text = `${value.toLowerCase()} · Hold Talk to speak`;
this.title.text = value === 'SLEEPING' ? 'Jarvis · privacy' : 'Jarvis';
this.status.style_class = `jarvis-local jarvis-state-${value.toLowerCase()}`;
}
addChip(id, label, payload) {
const chip = new St.Button({ label: safeText(label), style_class: 'jarvis-chip jarvis-chip-suggested', reactive: true, can_focus: true });
chip.accessible_name = `Suggested action: ${safeText(label)}`;
chip.connect('clicked', () => this.onSuggestion?.(id, payload));
this.chips.add_child(chip);
this.chipScroll.visible = true;
}
addStep(json) { try { const step = JSON.parse(json); this.addRow('J', `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`); } catch { this.addRow('J', json); } }
endTalk() { this.onTalk?.(false); }
destroy() { this.root.destroy(); }
}
export class JarvisOsd {
constructor() {
this.root = new St.BoxLayout({ style_class: 'jarvis-osd', visible: false });
this.label = new St.Label({ text: '', style_class: 'jarvis-osd-label' });
this.root.add_child(this.label);
this.root.accessible_name = 'Jarvis status overlay';
this._sticky = false;
this._timeout = 0;
}
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.hide(); }
_place() {
const monitor = Main.layoutManager.primaryMonitor;
if (!monitor) return;
const width = 220;
this.root.set_width(width);
this.root.set_position(monitor.x + Math.max(0, Math.round((monitor.width - width) / 2)), monitor.y + 36);
}
show(text, { sticky = false, ms = 2000 } = {}) {
this.label.text = safeText(text);
this._sticky = sticky;
this._place();
this.root.visible = true;
if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; }
const limit = sticky ? Math.min(ms || 8000, 8000) : ms;
this._timeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, limit, () => { this._timeout = 0; this.hide(); return GLib.SOURCE_REMOVE; });
}
setState(state) {
if (state === 'LISTENING') this.show('Listening', { sticky: true, ms: 8000 });
else if (state === 'SPEAKING') this.show('Speaking', { sticky: true, ms: 8000 });
else if (state === 'SLEEPING') this.show('Privacy mode', { sticky: false, ms: 1600 });
else this.hide();
}
showWake() { this.show('Wake', { sticky: false, ms: 1200 }); }
hide() { this.root.visible = false; this._sticky = false; if (this._timeout) { GLib.Source.remove(this._timeout); this._timeout = 0; } }
destroy() { this.hide(); this.root.destroy(); }
}
export class ComputerUseChrome {
constructor() {
this.root = new St.BoxLayout({ style_class: 'jarvis-cu', vertical: true, visible: false, reactive: false });
this.root.accessible_name = 'Jarvis computer use highlight';
this.job = new St.Label({ text: '', style_class: 'jarvis-job', visible: false });
this.target = new St.Label({ text: '', style_class: 'jarvis-target', visible: false });
this.cursor = new St.Label({ text: '', style_class: 'jarvis-agent-cursor', visible: false });
this.step = new St.Label({ text: '', style_class: 'jarvis-cu-step', visible: false });
this.root.add_child(this.job);
this.root.add_child(this.target);
this.root.add_child(this.cursor);
this.root.add_child(this.step);
}
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.hide(); }
_place() {
const monitor = Main.layoutManager.primaryMonitor;
if (!monitor) return;
const width = Math.min(360, monitor.width - 48);
this.root.set_width(width);
this.root.set_position(monitor.x + 24, monitor.y + 48);
}
_reveal() { this._place(); this.root.visible = true; }
addJob(id, pct, label) { this.job.text = `${safeText(label)} · ${Math.round(Number(pct) * 100)}%`; this.job.visible = true; this._reveal(); }
setTarget(json) { this.target.text = `${safeText(json)}`; this.cursor.text = '◎'; this.cursor.visible = true; this.target.visible = true; this._reveal(); }
addStep(json) {
try { const step = JSON.parse(json); this.step.text = `${step.n ? `${step.n}. ` : ''}${step.action || 'computer step'}`; }
catch { this.step.text = safeText(json); }
this.step.visible = true;
this._reveal();
}
hide() { this.root.visible = false; this.job.visible = false; this.target.visible = false; this.cursor.visible = false; this.step.visible = false; }
destroy() { this.root.destroy(); }
}
export class SessionPanel {
constructor() {
this.view = new ConversationView({ compact: false });
this.root = this.view.root;
this.minimized = true;
}
attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.hide(); }
show(force = false) {
if (this.minimized && !force) return;
this.minimized = false;
const monitor = Main.layoutManager.primaryMonitor;
if (monitor) {
const width = Math.min(SESSION_WIDTH, monitor.width - 48);
this.root.set_width(width);
this.root.set_position(monitor.x + Math.max(24, monitor.width - width - 24), monitor.y + 40);
this.view.scroll.set_height(Math.max(120, Math.min(280, monitor.height - 360)));
}
this.root.visible = true;
}
hide() { this.view.endTalk(); this.root.visible = false; this.minimized = true; }
toggle() { this.root.visible ? this.hide() : this.show(true); }
destroy() { this.view.destroy(); }
}
+22 -1
View File
@@ -11,7 +11,28 @@ import { createPhase9GatewayTool } from '../skills/phase9-tools.js';
export class HarnessBridge extends EventEmitter {
constructor({ cwd = process.cwd(), model = 'qwen3.5-4b', tools = [], computer, observer, actuator, permissionMode = 'ask' } = {}) {
super();
this.options = { cwd, model, tools: [...createRuntimeTools({ computer }), ...createPhase2Tools({ cwd, computer }), ...createComputerObserveTools({ computer, observer }), ...createComputerActTools({ actuator }), ...createQvacTools(), ...createPhase9GatewayTool(), ...tools], permissionMode, origin: 'jarvis-qvac', system: VOICE_SYSTEM_PROMPT };
this.options = {
cwd,
model,
tools: [
...createRuntimeTools({ computer }),
...createPhase2Tools({ cwd, computer }),
...createComputerObserveTools({ computer, observer }),
...createComputerActTools({ actuator }),
...createQvacTools(),
...createPhase9GatewayTool(),
...tools,
],
builtinTools: ['read_file', 'list_dir', 'grep', 'run_terminal_cmd', 'web_fetch', 'web_search'],
webFetch: true,
permissionMode,
origin: 'jarvis-qvac',
system: VOICE_SYSTEM_PROMPT,
voice: true,
maxTurns: 6,
maxShellCalls: 1,
maxToolRounds: 4,
};
this.session = null;
}
+11 -8
View File
@@ -102,21 +102,24 @@ export class JarvisDaemon extends EventEmitter {
if (this.state === 'SPEAKING') this.setState('LISTENING');
}
_speakReply(spoken) {
if (!this.voiceLoop) {
this._finishSpeech();
return;
}
const play = async () => {
await this.ensureTts();
const playing = this.voiceLoop?.speak?.(spoken);
if (!playing) {
if (!this.voiceLoop?.status?.tts) {
const reason = this.voiceLoop?.status?.errors?.tts;
if (reason && reason !== 'Spoken replies are disabled') this.emit('Error', 'TTS', reason);
this._finishSpeech();
return;
}
await playing;
await this.voiceLoop.speak(spoken);
};
Promise.resolve(play()).catch((error) => {
this.emit('Error', 'TTS', error.message);
if (this.state === 'SPEAKING') this._finishSpeech();
});
Promise.resolve(play())
.catch((error) => {
this.emit('Error', 'TTS', error.message);
})
.finally(() => this._finishSpeech());
}
async ensureTts() {
if (!this.voiceLoop) return;
+57
View File
@@ -1,5 +1,62 @@
export const MIN_UTTERANCE_CHARS = 3;
const SPOKEN_ACRONYMS = {
qvac: 'Quantum Verse Automatic Computer',
cpu: 'C P U',
gpu: 'G P U',
ram: 'R A M',
ssd: 'S S D',
hdd: 'H D D',
usb: 'U S B',
dns: 'D N S',
isp: 'I S P',
vpn: 'V P N',
ssh: 'S S H',
api: 'A P I',
url: 'U R L',
uri: 'U R I',
http: 'H T T P',
https: 'H T T P S',
html: 'H T M L',
json: 'J S O N',
xml: 'X M L',
os: 'O S',
ip: 'I P',
tts: 'T T S',
asr: 'A S R',
hud: 'heads up display',
llm: 'L L M',
cli: 'C L I',
gui: 'G U I',
ptt: 'P T T',
vad: 'V A D',
};
function spellAcronyms(text) {
return String(text || '').replace(/\b([A-Za-z]{2,6})\b/g, (word) => {
const spoken = SPOKEN_ACRONYMS[word.toLowerCase()];
return spoken || word;
});
}
function speakableAddresses(text) {
return String(text || '')
.replace(/\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b/g, '$1 $2 $3 $4')
.replace(/https?:\/\//gi, '')
.replace(/\b([A-Za-z0-9-]+)\.(com|org|net|io|dev|local|lan)\b/gi, (_, host, tld) => `${host} dot ${tld}`)
.replace(/\//g, ' slash ')
.replace(/@/g, ' at ')
.replace(/_/g, ' ');
}
export function speakableForTts(text) {
return spellAcronyms(speakableAddresses(String(text || '')))
.replace(/[`]/g, "'")
.replace(/[<>]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function isSpeakable(text) {
const value = String(text || '').trim();
if (!value || value === '[object Object]') return false;
+1 -1
View File
@@ -49,7 +49,7 @@ export class QvacVoiceAdapter extends EventEmitter {
if (!this.ttsId) throw new Error('Speech output is unavailable');
const sdk = await qvacSdk();
const samples = await withQvacMaster(async () => {
const result = await sdk.textToSpeech({ modelId: this.ttsId, text: String(text), inputType: 'text', stream: false });
const result = await sdk.textToSpeech({ modelId: this.ttsId, text: String(text).replace(/[`]/g, "'"), inputType: 'text', stream: false });
if (result?.buffer != null) return await result.buffer;
if (result?.bufferStream) {
const chunks = [];
+32 -13
View File
@@ -3,7 +3,7 @@ import { PipeWireCapture } from './audio-pipewire.js';
import { PipeWirePlayback } from './audio-playback.js';
import { WakeEngine } from './wake-engine.js';
import { VadSegmenter } from './vad.js';
import { isMeaningfulTranscript, isSpeakable, SentenceBuffer } from './transcript.js';
import { isMeaningfulTranscript, isSpeakable, speakableForTts, SentenceBuffer } from './transcript.js';
import { VoiceMetrics } from './voice-metrics.js';
export const POST_PLAYBACK_COOLDOWN_MS = 400;
@@ -91,29 +91,48 @@ export class VoiceLoop extends EventEmitter {
_releaseSpeaking() {
this.isSpeaking = false;
this.wake.resume();
if (this.daemon?.state === 'SPEAKING') { this.daemon.voice?.finishSpeaking?.(); this.daemon.setState?.('LISTENING'); }
if (this.daemon?.state === 'SPEAKING') {
try { this.daemon.voice?.finishSpeaking?.(); } catch {}
this.daemon.setState?.('LISTENING');
}
}
async speak(text) {
const generation = this._generation || 0;
if (!isSpeakable(text) || !this.tts?.speak || !this.status.tts) { this._releaseSpeaking(); return; }
if (!isSpeakable(text) || !this.tts?.speak || !this.status.tts) {
this._releaseSpeaking();
return;
}
this.metrics.reply();
const sentences = String(text).match(/[^.!?]+[.!?]+|[^.!?]+$/g)?.map((part) => part.trim()).filter(isSpeakable) || [];
if (!sentences.length) { this._releaseSpeaking(); return; }
if (!sentences.length) {
this._releaseSpeaking();
return;
}
this._speechQueue = this._speechQueue.catch(() => {}).then(async () => {
if (generation !== this._generation) return;
for (const sentence of sentences) {
try {
if (generation !== this._generation) return;
await this.speakSentence(sentence);
for (const sentence of sentences) {
if (generation !== this._generation) return;
await this.speakSentence(sentence, generation);
}
} finally {
if (generation === this._generation) this._releaseSpeaking();
}
});
return this._speechQueue;
}
async speakSentence(text) {
this.isSpeaking = true; this.wake.pause(); this.daemon?.emit('StateChanged', 'SPEAKING');
try { const audio = await this.tts.speak(text); await this.playback.play(audio.samples); try { this.daemon?.emit('SpeakingLevel', 0); } catch {} }
finally {
this.isSpeaking = false; this.cooldownUntil = this.now() + this.cooldownMs; this.wake.resume();
if (this.daemon?.state === 'SPEAKING') { this.daemon.voice?.finishSpeaking?.(); this.daemon.setState?.('LISTENING'); }
async speakSentence(text, generation) {
this.isSpeaking = true;
this.wake.pause();
try {
const audio = await this.tts.speak(speakableForTts(text));
if (generation !== this._generation) return;
await this.playback.play(audio.samples);
try { this.daemon?.emit('SpeakingLevel', 0); } catch {}
} finally {
this.isSpeaking = false;
this.cooldownUntil = this.now() + this.cooldownMs;
this.wake.resume();
}
}
}
+8 -5
View File
@@ -27,9 +27,12 @@ case "${MODEL_PROFILE}" in
laptop-8gb|laptop-16gb|desktop-gpu) ;;
*) echo "Unknown model profile: ${MODEL_PROFILE}" >&2; exit 2 ;;
esac
read -r -p "Enable TTS preview? [Y/n]: " TTS_CHOICE
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
[[ "${TTS_CHOICE:-Y}" =~ ^[Nn]$ ]] && TTS_ENABLED=false
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}"
@@ -52,12 +55,12 @@ if command -v systemctl >/dev/null && command -v busctl >/dev/null && [[ -n "${D
fi
echo "[4/5] TTS preview"
if [[ "${TTS_ENABLED}" == true && "${DAEMON_BUS_READY}" == true ]]; then
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 [[ "${TTS_ENABLED}" == true ]]; then
elif [[ "${PLAY_PREVIEW}" == true ]]; then
echo "Daemon did not register io.qvac.Jarvis; preview skipped."
else
echo "TTS is disabled; preview skipped."
echo "TTS preview skipped; spoken replies remain enabled."
fi
echo
echo "[5/5] Typed smoke test"
+2 -2
View File
@@ -4,7 +4,7 @@ export function createRuntimeTools({ computer } = {}) {
return [
{
name: 'jarvis_status',
description: 'Return the local Jarvis QVAC master status. Never claim cloud access.',
description: 'Return the local Jarvis QuantumVerse Automatic Computer master status.',
parameters: { type: 'object', properties: {} },
execute: () => ({
local: true,
@@ -14,7 +14,7 @@ export function createRuntimeTools({ computer } = {}) {
},
{
name: 'cu_status',
description: 'Return whether the explicit computer-use grant is active and its step budget.',
description: 'Return whether the user pressed Allow now for computer use, and the remaining step budget.',
parameters: { type: 'object', properties: {} },
execute: () => computer?.status?.() || { active: false, backend: 'none' },
},
+16 -6
View File
@@ -1,16 +1,26 @@
export const VOICE_SYSTEM_PROMPT = `You are Jarvis, a local Ubuntu GNOME voice assistant running through QVAC.
export const VOICE_SYSTEM_PROMPT = `You are Jarvis, a local Ubuntu GNOME voice assistant running through Quantum Verse Automatic Computer, spelled Q V A C.
Q V A C stands for Quantum Verse Automatic Computer. In speech say Quantum Verse Automatic Computer, or spell it as Q V A C. Never say the letters as one word.
Use short spoken replies of one to three sentences unless the user asks for detail.
Ground every desktop, file, memory, and model claim in a tool result. Never claim cloud access.
Computer use requires an explicit user grant; never click or type while it is inactive, locked, or revoked.
Destructive actions require confirmation in both the HUD and spoken conversation.
Every reply must be speakable out loud by text to speech. Write only words and numbers a person can say.
Reply in plain text only. Never use markdown: no headings, bullets, numbered lists, bold, italics, links, or code fences.
Never use acronyms or initialisms as a single word. Spell them as separate letters, for example C P U, G P U, I P, U R L, H T T P, R A M, S S D, U S B, D N S, I S P. Prefer full words when they exist (central processing unit, graphics processor, memory, operating system).
Never speak punctuation. Internet protocol addresses have no dots: say 192 168 0 1, never 192.168.0.1. Host names use the word dot, for example example dot com. Paths and addresses use the word slash, never a slash character. Colons, underscores, hyphens, and at signs are the words colon, underscore, dash, and at.
Ground every desktop, file, memory, model, and network claim in a tool result.
The language model runs locally on this computer. This computer can reach the internet.
For any H T T P or H T T P S address, call web_fetch and speak the facts from the result. Do not use curl or wget. Never say that network or public sites are unavailable unless web_fetch itself failed.
Computer use requires an explicit user grant from Settings, Computer use, Allow now, or Grant desktop in the tray. Never click or type while it is inactive, locked, or revoked. Never ask for passwords or credentials.
Destructive actions require confirmation in both the heads-up display and spoken conversation.
Prefer structured tools and accessibility references over coordinates.
For questions about the computer, files, processes, or system state, call the
most relevant registered tool before answering. Never say that computer use or
terminal access is unavailable unless a tool result reports that limitation.
When the user asks to use the command line or terminal, call run_terminal_cmd
and base the answer on its result; do not substitute a refusal or guess.
once, then speak the result. Do not chain extra commands (hostnamectl then
uname then free) unless the previous result was an error.
If a tool result contains stdout or a command listing, quote the facts from it in speakable words.
Do not say you received no output unless the result is exactly "(no output)".
When a useful follow-up action exists, append a HUD sidecar exactly as <jarvis_hud>{"title":"...","chips":[{"id":"...","label":"..."}],"confirmation":null}</jarvis_hud>.
The sidecar is for the HUD and must not be spoken.`;
The sidecar is for the heads-up display and must not be spoken.`;
export function parseHudSidecar(text) {
const source = String(text || '');
+88
View File
@@ -0,0 +1,88 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { VOICE_SYSTEM_PROMPT } from '../skills/voice-prompt.js';
const require = createRequire(import.meta.url);
const compaction = require('../vendor/agent-harness/agent/compaction.js');
const prompts = require('../vendor/agent-harness/agent/prompts.js');
function hugeTools() {
return Array.from({ length: 24 }, (_, i) => ({
name: 'tool_' + i,
description: 'schema '.repeat(80),
parameters: { type: 'object', properties: { q: { type: 'string' } } },
}));
}
test('short voice chats do not compact just because tool schemas are large', () => {
const tools = hugeTools();
const hist = [
{ role: 'system', content: VOICE_SYSTEM_PROMPT },
{ role: 'assistant', content: 'Hello! How can I help you today?' },
{ role: 'user', content: 'Hi, please tell me about my computer.' },
];
assert.equal(compaction.shouldCompact(hist, tools, 8192), false);
const out = compaction.compact(hist, {
budgetTokens: compaction.historyBudget(8192, tools, 0),
tools,
voice: true,
});
assert.equal(out.length, hist.length);
assert.equal(out[2].content, hist[2].content);
assert.equal(JSON.stringify(out).includes('Earlier turns were compacted'), false);
});
test('heuristic compact does not double-count tools against the history budget', () => {
const tools = hugeTools();
const hist = [
{ role: 'system', content: 'You are Jarvis' },
{ role: 'assistant', content: 'Hello! How can I help you today?' },
{ role: 'user', content: 'Hi, please tell me about my computer.' },
{ role: 'assistant', content: 'Let me check that.' },
{ role: 'user', content: 'Please continue.' },
];
const budget = compaction.historyBudget(8192, tools, 0);
assert.ok(budget <= 240 || compaction.toolTokens(tools) > 1000);
const out = compaction.heuristicCompact(hist, { budgetTokens: budget, tools });
assert.ok(out.some((m) => String(m.content).includes('tell me about my computer')));
assert.equal(JSON.stringify(out).includes('Earlier turns were compacted'), false);
});
test('voice compaction does not auto-continue a new greeting', () => {
const cont = compaction.autoContinue([{ role: 'assistant', content: 'Hello!' }], { voice: true });
assert.equal(cont, null);
assert.match(compaction.compactReminder({ voice: true }), /Do not greet again/);
});
test('voice assemble uses only the Jarvis prompt', () => {
const sys = prompts.assemble({
personality: 'voice',
extra: VOICE_SYSTEM_PROMPT,
cwd: '/home/raven/.local/share/jarvis-qvac',
hostWorkspace: true,
fsRead: () => 'You are a local coding agent. Read AGENTS.md.',
});
assert.match(sys, /You are Jarvis/);
assert.doesNotMatch(sys, /You are a local coding agent/);
assert.doesNotMatch(sys, /AGENTS.md/);
});
test('LLM compact skips a two-turn voice chat', async () => {
let called = false;
const hist = [
{ role: 'system', content: 'You are Jarvis' },
{ role: 'assistant', content: 'Hello! How can I help you today?' },
{ role: 'user', content: 'Hi, please tell me about my computer.' },
];
const out = await compaction.compactWithLlm(hist, {
voice: true,
complete: async () => {
called = true;
return { text: '1. Latest user request\n2. Facts\n3. Answered\n4. Follow-ups\n' };
},
});
assert.equal(called, false);
assert.equal(out.length, hist.length);
assert.equal(out[2].content, hist[2].content);
});
+20 -2
View File
@@ -62,6 +62,23 @@ test('ask extracts harness reply text instead of stringifying the object', async
}
});
test('harness bridge caps voice shell chaining', () => {
const bridge = new HarnessBridge();
assert.equal(bridge.options.origin, 'jarvis-qvac');
assert.equal(bridge.options.voice, true);
assert.equal(bridge.options.maxShellCalls, 1);
assert.equal(bridge.options.maxTurns, 6);
assert.deepEqual(bridge.options.builtinTools, [
'read_file',
'list_dir',
'grep',
'run_terminal_cmd',
'web_fetch',
'web_search',
]);
assert.equal(bridge.options.webFetch, true);
});
test('harness bridge recovers streamed text when final envelope is empty after a tool call', async () => {
const session = new (await import('node:events')).EventEmitter();
session.prompt = async () => {
@@ -92,13 +109,14 @@ test('ask with no spoken text after tools returns to listening without a Reply',
}
});
test('confirmPermission forwards Allow/Deny to the harness session', async () => {
test('confirmPermission forwards Allow/Deny/Always to the harness session', async () => {
const daemon = new JarvisDaemon();
const calls = [];
daemon.harness = { session: { permit(...args) { calls.push(args); } }, cancel() {}, close: async () => {} };
try {
daemon.confirmPermission('job-1', 'call-9', 'allow');
assert.deepEqual(calls, [['job-1', 'call-9', 'allow']]);
daemon.confirmPermission('job-1', 'call-9', 'always');
assert.deepEqual(calls, [['job-1', 'call-9', 'allow'], ['job-1', 'call-9', 'always']]);
} finally {
await daemon.close();
}
+289 -97
View File
@@ -3,41 +3,93 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const source = readFileSync(new URL('../apps/gnome-extension/[email protected]/extension.js', import.meta.url), 'utf8');
const uiSource = readFileSync(new URL('../apps/gnome-extension/[email protected]/ui.js', import.meta.url), 'utf8');
const extensionSource = readFileSync(new URL('../apps/gnome-extension/[email protected]/extension.js', import.meta.url), 'utf8');
function stripModules(source) {
return source
.replace(/^import(?:\s+type)?\s+[\s\S]*?from\s+['"][^'"]+['"];\s*$/gm, '')
.replace(/^export default class/gm, 'class')
.replace(/^export class/gm, 'class')
.replace(/^export const /gm, 'const ')
.replace(/^export function /gm, 'function ')
.replace(/^export \{[\s\S]*?\};$/gm, '');
}
function harness() {
const timers = new Map();
const chrome = [];
class Actor {
constructor(props = {}) {
if ('hexpand' in props) throw new Error('No property hexpand on StWidget');
Object.assign(this, props);
this.children = [];
this.visible = props.visible !== false;
this.text = props.text || props.hint_text || '';
this.clutter_text = { connect() {}, ellipsize: null };
}
add_child(child) { this.children.push(child); }
add_child(child) { this.children.push(child); child.get_parent = () => this; }
remove_child(child) { this.children = this.children.filter((item) => item !== child); }
destroy_all_children() { this.children = []; }
get_n_children() { return this.children.length; }
contains() { return false; }
get_last_child() { return this.children[this.children.length - 1] || null; }
connect() { return 1; }
get_first_child() { return this.children[0] || null; }
connect(name, handler) {
this.handlers = this.handlers || {};
this.handlers[name] = handler;
return 1;
}
hide() { this.visible = false; }
show() { this.visible = true; }
destroy() { this.destroyed = true; }
destroy() { this.destroyed = true; this.visible = false; }
set_position() {}
set_width() {}
set_height() {}
grab_key_focus() {}
set_style() {}
add_style_class_name() {}
get_first_child() { return this.children[0] || null; }
get_text() { return this.text || ''; }
set_text(value) { this.text = value; }
}
const menu = {
box: new Actor(),
actor: new Actor(),
opened: false,
connect(_name, handler) { this._handler = handler; return 2; },
disconnect() {},
addMenuItem(item) { this.box.add_child(item.actor || item); },
open() { this.opened = true; this._handler?.(this, true); },
close() { this.opened = false; this._handler?.(this, false); },
removeAll() { this.box.destroy_all_children(); },
};
const context = vm.createContext({
Extension: class {},
global: { stage: { get_key_focus() { return null; } } },
St: { BoxLayout: Actor, Label: Actor, Widget: Actor, Button: Actor, Entry: Actor, ScrollView: Actor, PolicyType: { NEVER: 0, AUTOMATIC: 1 } },
Clutter: { ActorAlign: { CENTER: 0, START: 1 }, EVENT_STOP: 1, EVENT_PROPAGATE: 0 },
Clutter: { ActorAlign: { CENTER: 0, START: 1 }, EVENT_STOP: 1, EVENT_PROPAGATE: 0, KEY_space: 32, KEY_Return: 65293, KEY_Escape: 65307 },
Pango: { WrapMode: { WORD_CHAR: 2 }, EllipsizeMode: { NONE: 0, END: 3 } },
Main: { layoutManager: { addChrome() {}, primaryMonitor: { x: 0, y: 0, width: 1920, height: 1080 } } },
PopupMenu: {
PopupBaseMenuItem: class extends Actor {
constructor(props) { super(props); this.actor = this; }
},
PopupMenuItem: class extends Actor {
constructor(props) { super(props); this.actor = this; this.label = typeof props === 'string' ? props : props?.label; }
},
PopupSeparatorMenuItem: class extends Actor { constructor() { super(); this.actor = this; } },
PopupMenuSection: class extends Actor {
constructor() { super(); this.actor = this; this.box = this; }
},
},
PanelMenu: { Button: class extends Actor {
constructor() { super(); this.menu = menu; }
} },
Main: {
layoutManager: { addChrome(actor) { chrome.push(actor); }, primaryMonitor: { x: 0, y: 0, width: 1920, height: 1080 } },
panel: { addToStatusArea() {} },
wm: { addKeybinding() {}, removeKeybinding() {} },
screenShield: { connect() { return 1; }, disconnect() {}, locked: false },
},
GLib: {
PRIORITY_DEFAULT: 0, PRIORITY_DEFAULT_IDLE: 200, SOURCE_REMOVE: false,
idle_add(_priority, callback) { callback(); return 1; },
@@ -46,75 +98,98 @@ function harness() {
},
log() {},
});
vm.runInContext(source.replace(/^import .*;\n/gm, '').replace('export default class', 'class') +
'\nglobalThis.classes = { ArcOverlay, JarvisExtension, JarvisProxy };', context);
return { ...context.classes, timers };
vm.runInContext(
`${stripModules(uiSource)}\n${stripModules(extensionSource)}\nglobalThis.classes = { ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel, JarvisExtension, JarvisProxy };`,
context,
);
return { ...context.classes, timers, chrome, menu };
}
test('overlay constructs and destroys with pending halo animation', () => {
const { ArcOverlay, timers } = harness();
const overlay = new ArcOverlay();
assert.equal(overlay.header.children[1].x_expand, true);
overlay.attach();
overlay.show(true);
overlay.showHalo();
overlay.showHalo();
assert.equal(timers.size, 1);
overlay.destroy();
assert.equal(timers.size, 0);
test('compact popup constructs without a floating 720px overlay', () => {
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
assert.equal(popup.compact, true);
assert.match(popup.root.style_class, /jarvis-popup/);
assert.equal(popup.header.children[1].x_expand, true);
assert.equal(popup.expand.label, 'Open');
assert.equal(popup.expand.accessible_name, 'Open conversation');
assert.equal(popup.settings.label, 'Settings');
assert.equal(popup.header.children.includes(popup.settings), true);
assert.doesNotMatch(popup.root.style_class, /jarvis-arc/);
});
test('empty chrome widgets start hidden', () => {
const { ArcOverlay } = harness();
const overlay = new ArcOverlay();
assert.equal(overlay.job.visible, false);
assert.equal(overlay.target.visible, false);
assert.equal(overlay.cursor.visible, false);
assert.equal(overlay.wave.visible, false);
const { ConversationView, ComputerUseChrome } = harness();
const popup = new ConversationView({ compact: true });
const cu = new ComputerUseChrome();
assert.equal(popup.confirm.visible, false);
assert.equal(popup.thinking.visible, false);
assert.equal(popup.chipScroll.visible, false);
assert.equal(cu.root.visible, false);
assert.equal(cu.target.visible, false);
assert.equal(cu.cursor.visible, false);
});
test('Reply finalizes a streaming row instead of duplicating [object Object]', () => {
const { ArcOverlay } = harness();
const overlay = new ArcOverlay();
overlay.token('Hello! I am ready.');
overlay.finalizeReply('[object Object]');
overlay.finalizeReply({ text: 'ignored duplicate' });
assert.equal(overlay.transcript.children.length, 1);
assert.match(overlay.transcript.children[0].text, /Hello! I am ready/);
assert.doesNotMatch(overlay.transcript.children[0].text, /object Object/);
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
popup.token('Hello! I am ready.');
popup.finalizeReply('[object Object]');
popup.finalizeReply({ text: 'ignored duplicate' });
assert.equal(popup.transcript.children.length, 1);
assert.match(popup.transcript.children[0].text, /Hello! I am ready/);
assert.doesNotMatch(popup.transcript.children[0].text, /object Object/);
});
test('final reply is shown after tool result rows', () => {
const { ArcOverlay } = harness();
const overlay = new ArcOverlay();
overlay.addToolCall('{"name":"runtime_status"}');
overlay.addToolResult('{"name":"runtime_status"}');
overlay.finalizeReply('Your computer is ready.');
assert.equal(overlay.transcript.children.length, 3);
assert.match(overlay.transcript.children[2].text, /Your computer is ready/);
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
popup.addToolCall('{"name":"runtime_status"}');
popup.addToolResult('{"name":"runtime_status"}');
popup.finalizeReply('Your computer is ready.');
assert.equal(popup.transcript.children.length, 3);
assert.match(popup.transcript.children[2].text, /Your computer is ready/);
});
test('tokens after a tool result start a new spoken Jarvis row', () => {
const { ArcOverlay } = harness();
const overlay = new ArcOverlay();
overlay.addToolCall('{"name":"capability_status"}');
overlay.addToolResult('{"name":"capability_status"}');
overlay.token('Your computer is ready.');
overlay.finalizeReply('Your computer is ready.');
assert.equal(overlay.transcript.children.length, 3);
assert.match(overlay.transcript.children[2].text, /Your computer is ready/);
assert.doesNotMatch(overlay.transcript.children[2].style_class, /jarvis-row-tool/);
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
popup.addToolCall('{"name":"capability_status"}');
popup.addToolResult('{"name":"capability_status"}');
popup.token('Your computer is ready.');
popup.finalizeReply('Your computer is ready.');
assert.equal(popup.transcript.children.length, 3);
assert.match(popup.transcript.children[2].text, /Your computer is ready/);
assert.doesNotMatch(popup.transcript.children[2].style_class, /jarvis-row-tool/);
});
test('whitespace-only tokens do not open an empty Jarvis row', () => {
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
popup.token(' \n');
assert.equal(popup.transcript.children.length, 0);
popup.token('Hostname is nest.');
assert.equal(popup.transcript.children.length, 1);
popup.token(' ');
assert.match(popup.transcript.children[0].text, /Hostname is nest/);
});
test('addRow and token coerce objects to readable text', () => {
const { ArcOverlay } = harness();
const overlay = new ArcOverlay();
overlay.addRow('U', { text: 'Hi there' });
overlay.token({ text: 'Hello from Jarvis' });
assert.equal(overlay.transcript.children.length, 2);
assert.match(overlay.transcript.children[0].text, /Hi there/);
assert.match(overlay.transcript.children[1].text, /Hello from Jarvis/);
assert.doesNotMatch(overlay.transcript.children.map((row) => row.text).join('\n'), /object Object/);
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
popup.addRow('U', { text: 'Hi there' });
popup.token({ text: 'Hello from Jarvis' });
assert.equal(popup.transcript.children.length, 2);
assert.match(popup.transcript.children[0].text, /Hi there/);
assert.match(popup.transcript.children[1].text, /Hello from Jarvis/);
assert.doesNotMatch(popup.transcript.children.map((row) => row.text).join('\n'), /object Object/);
});
test('popup transcript stays short', () => {
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
for (let i = 0; i < 12; i++) popup.addRow('U', `line ${i}`);
assert.equal(popup.transcript.children.length, 8);
});
for (const fails of [false, true]) {
@@ -125,22 +200,33 @@ for (const fails of [false, true]) {
extension.proxy = { connect: () => new Promise((resolve, reject) => { finish = fails ? reject : resolve; }) };
const pending = extension._connectDaemon();
extension.proxy = null;
extension.overlay = null;
extension.popup = null;
extension.session = null;
finish(fails ? new Error('Disconnected') : undefined);
await pending;
});
}
test('GNOME GI imports expose default namespaces and panel has a real menu', () => {
assert.match(source, /import Shell from 'gi:\/\/Shell'/);
assert.match(source, /import Meta from 'gi:\/\/Meta'/);
assert.match(source, /import Pango from 'gi:\/\/Pango'/);
assert.match(source, /new PanelMenu.Button\(0.0, 'Jarvis QVAC', false\)/);
assert.match(source, /PushToTalk/);
assert.match(source, /finalizeReply/);
assert.match(source, /\['Thinking'/);
assert.match(source, /\['ToolCall'/);
assert.match(source, /Minimize/);
assert.match(extensionSource, /import Shell from 'gi:\/\/Shell'/);
assert.match(extensionSource, /import Meta from 'gi:\/\/Meta'/);
assert.match(extensionSource, /new PanelMenu.Button\(0.0, 'Jarvis QVAC', false\)/);
assert.match(extensionSource, /PushToTalk/);
assert.match(extensionSource, /\['Thinking'/);
assert.match(extensionSource, /\['ToolCall'/);
assert.match(extensionSource, /_openPopup/);
assert.match(uiSource, /Open conversation/);
assert.match(extensionSource, /PopupMenuSection/);
assert.match(extensionSource, /_openSettings/);
assert.match(extensionSource, /PopupMenuItem\('Settings'\)/);
assert.match(extensionSource, /PopupMenuItem\('Grant desktop'\)/);
assert.doesNotMatch(uiSource, /button-press-event', \(\) => Clutter\.EVENT_STOP/);
assert.match(uiSource, /notify::pressed/);
assert.match(extensionSource, /ComputerGrant/);
assert.match(extensionSource, /OpenExtensionPrefs/);
assert.match(uiSource, /finalizeReply/);
assert.doesNotMatch(extensionSource, /this\.overlay\.show\(true\)/);
assert.doesNotMatch(extensionSource, /Minimize/);
});
test('D-Bus confirmation signal does not call missing Interface.emit', () => {
@@ -151,42 +237,143 @@ test('D-Bus confirmation signal does not call missing Interface.emit', () => {
assert.match(dbusSource, /Confirm: \{ inSignature: 'sss'/);
});
test('minimize stays closed while Jarvis speaks, then restore shows the transcript', () => {
const { ArcOverlay } = harness();
const overlay = new ArcOverlay();
overlay.attach();
overlay.show(true);
overlay.minimize();
overlay.setState('SPEAKING');
overlay.finalizeReply('I am still talking in the background.');
assert.equal(overlay.root.visible, false);
overlay.show(true);
assert.equal(overlay.root.visible, true);
assert.match(overlay.transcript.children[0].text, /still talking/);
test('session panel stays closed while Jarvis speaks unless expanded', () => {
const { SessionPanel } = harness();
const session = new SessionPanel();
session.attach();
session.view.setState('SPEAKING');
session.view.finalizeReply('I am still talking in the background.');
assert.equal(session.root.visible, false);
session.show(true);
assert.equal(session.root.visible, true);
assert.match(session.view.transcript.children[0].text, /still talking/);
});
test('OSD shows listening without opening a conversation panel', () => {
const { JarvisOsd, SessionPanel, timers } = harness();
const osd = new JarvisOsd();
const session = new SessionPanel();
osd.attach();
session.attach();
osd.setState('LISTENING');
assert.equal(osd.root.visible, true);
assert.match(osd.label.text, /Listening/);
assert.equal(session.root.visible, false);
osd.setState('ARMED');
assert.equal(osd.root.visible, false);
osd.setState('SPEAKING');
assert.equal(osd.root.visible, true);
assert.ok(timers.size >= 1);
osd.showWake();
assert.equal(timers.size, 1);
osd.destroy();
assert.equal(timers.size, 0);
});
test('computer-use chrome shows a target without a transcript', () => {
const { ComputerUseChrome } = harness();
const cu = new ComputerUseChrome();
cu.attach();
assert.equal(cu.root.visible, false);
cu.setTarget('{"rect":[1,2,3,4]}');
assert.equal(cu.root.visible, true);
assert.equal(cu.target.visible, true);
assert.equal(cu.cursor.visible, true);
assert.ok(!cu.transcript);
cu.hide();
assert.equal(cu.root.visible, false);
});
test('ConfirmationRequired chips answer Confirm with job and tool ids', () => {
const { ArcOverlay } = harness();
const overlay = new ArcOverlay();
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
const answers = [];
overlay.onConfirm = (...args) => answers.push(args);
overlay.offerConfirm('write_file', JSON.stringify({ jobId: 'job-1', toolCallId: 'call-9', args: { path: '~/notes' } }), 'destructive');
assert.equal(overlay.confirm.visible, true);
overlay._answerConfirm('allow');
assert.equal(overlay.confirm.visible, false);
const shown = [];
popup.onConfirm = (...args) => answers.push(args);
popup.onConfirmShown = () => shown.push(true);
popup.offerConfirm('write_file', JSON.stringify({ jobId: 'job-1', toolCallId: 'call-9', args: { path: '~/notes' } }), 'destructive');
assert.equal(popup.confirm.visible, true);
assert.equal(popup.confirmButtons.children.length, 3);
assert.equal(popup.confirmButtons.children[1].label, 'Always allow');
assert.equal(shown.length, 1);
popup._answerConfirm('allow');
assert.equal(popup.confirm.visible, false);
assert.deepEqual(answers, [['job-1', 'call-9', 'allow']]);
});
test('Always allow Confirm remembers the decision string', () => {
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
const answers = [];
popup.onConfirm = (...args) => answers.push(args);
popup.offerConfirm('run_terminal_cmd', JSON.stringify({ jobId: 'job-2', toolCallId: 'call-3', args: { command: 'uname -a' } }), 'uname -a');
assert.match(popup.confirmLabel.text, /uname -a/);
popup._answerConfirm('always');
assert.deepEqual(answers, [['job-2', 'call-3', 'always']]);
});
test('tool results show a stdout preview instead of only complete', () => {
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
popup.addToolCall('{"name":"run_terminal_cmd"}');
popup.addToolResult(JSON.stringify({ name: 'run_terminal_cmd', result: 'Linux 6.8\nexit 0' }));
assert.match(popup.transcript.children[1].text, /Linux 6\.8/);
});
test('errors and reset failures are one-line notices, not chat rows', () => {
const { ArcOverlay } = harness();
const overlay = new ArcOverlay();
overlay.setNotice('ResetContext failed:\nTypeError: Cannot read properties of undefined (reading \'apply\')');
assert.equal(overlay.transcript.children.length, 0);
assert.equal(overlay.notice.visible, true);
assert.doesNotMatch(overlay.notice.text, /\n/);
overlay.clear();
overlay.setNotice('New conversation');
assert.equal(overlay.notice.text, 'New conversation');
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
popup.setNotice('ResetContext failed:\nTypeError: Cannot read properties of undefined (reading \'apply\')');
assert.equal(popup.transcript.children.length, 0);
assert.equal(popup.notice.visible, true);
assert.doesNotMatch(popup.notice.text, /\n/);
popup.clear();
popup.setNotice('New conversation');
assert.equal(popup.notice.text, 'New conversation');
});
test('Settings chip is in the header and opens preferences', () => {
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
const calls = [];
popup.onSettings = () => calls.push('settings');
popup.settings.handlers.clicked();
assert.deepEqual(calls, ['settings']);
const session = new ConversationView({ compact: false });
session.onSettings = () => calls.push('session');
session.settings.handlers.clicked();
assert.deepEqual(calls, ['settings', 'session']);
});
test('HUD chips and hold-to-talk receive clicks instead of swallowing them', () => {
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
const calls = [];
popup.onStop = () => calls.push('stop');
popup.onReset = () => calls.push('reset');
popup.onExpand = () => calls.push('open');
popup.onSettings = () => calls.push('settings');
popup.onTalk = (pressed) => calls.push(pressed ? 'talk-down' : 'talk-up');
popup.stop.handlers.clicked();
popup.reset.handlers.clicked();
popup.expand.handlers.clicked();
popup.settings.handlers.clicked();
popup.talk.pressed = true;
popup.talk.handlers['notify::pressed']();
popup.talk.pressed = false;
popup.talk.handlers['notify::pressed']();
assert.deepEqual(calls, ['stop', 'reset', 'open', 'settings', 'talk-down', 'talk-up']);
assert.equal(popup.stop.handlers['button-press-event'], undefined);
assert.equal(popup.settings.handlers['button-press-event'], undefined);
});
test('closing the popup ends hold-to-talk', () => {
const { ConversationView } = harness();
const popup = new ConversationView({ compact: true });
const talks = [];
popup.onTalk = (pressed) => talks.push(pressed);
popup.endTalk();
assert.deepEqual(talks, [false]);
});
test('prefs bind every GSettings schema key', () => {
@@ -198,4 +385,9 @@ test('prefs bind every GSettings schema 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/);
});
+10 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -127,3 +127,12 @@ exit 0
await rm(work, { recursive: true, force: true });
}
});
test('first-run keeps TTS enabled even when the preview is skipped', () => {
const source = readFileSync(new URL('../packaging/first-run.sh', import.meta.url), 'utf8');
assert.match(source, /Play a TTS preview now\?/);
assert.match(source, /TTS_ENABLED=true/);
assert.doesNotMatch(source, /Enable TTS preview\?/);
assert.doesNotMatch(source, /TTS_ENABLED=false/);
assert.match(source, /spoken replies remain enabled/);
});
+16 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createRuntimeTools } from '../skills/runtime-tools.js';
import { assertSdkVersion } from '../daemon/qvac-master.js';
import { parseHudSidecar } from '../skills/voice-prompt.js';
import { parseHudSidecar, VOICE_SYSTEM_PROMPT } from '../skills/voice-prompt.js';
import { createPhase2Tools } from '../skills/phase2-tools.js';
import { createQvacTools } from '../skills/qvac-tools.js';
import { profile } from '../daemon/model-profiles.js';
@@ -26,6 +26,21 @@ test('voice sidecars are removed from speech and retained for the HUD', () => {
assert.equal(parsed.hud.title, 'Done');
});
test('voice prompt tells the model not to chain extra terminal commands', () => {
assert.match(VOICE_SYSTEM_PROMPT, /call run_terminal_cmd\nonce/s);
assert.match(VOICE_SYSTEM_PROMPT, /Do not chain extra commands/);
assert.match(VOICE_SYSTEM_PROMPT, /Reply in plain text only/);
assert.match(VOICE_SYSTEM_PROMPT, /Never use markdown/);
assert.match(VOICE_SYSTEM_PROMPT, /Quantum Verse Automatic Computer/);
assert.match(VOICE_SYSTEM_PROMPT, /spell it as Q V A C/);
assert.match(VOICE_SYSTEM_PROMPT, /Internet protocol addresses have no dots/);
assert.match(VOICE_SYSTEM_PROMPT, /Spell them as separate letters/);
assert.match(VOICE_SYSTEM_PROMPT, /call web_fetch/);
assert.match(VOICE_SYSTEM_PROMPT, /This computer can reach the internet/);
assert.match(VOICE_SYSTEM_PROMPT, /Allow now/);
assert.doesNotMatch(VOICE_SYSTEM_PROMPT, /Never claim cloud access/);
});
test('phase 2 registers safe local tools with permission metadata', async () => {
const tools = createPhase2Tools({ cwd: process.cwd() });
assert.deepEqual(tools.map((tool) => tool.name), ['app_list', 'fs_search', 'fs_read', 'fs_write', 'memory_recall', 'memory_remember', 'rag_workspaces', 'capability_status']);
+56
View File
@@ -0,0 +1,56 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import os from 'node:os';
const require = createRequire(import.meta.url);
const tools = require('../vendor/agent-harness/agent/tools.js');
const permRules = require('../vendor/agent-harness/agent/perm-rules.js');
const toolBudget = require('../vendor/agent-harness/agent/tool-budget.js');
test('runShell captures stdout after the process closes', async () => {
const result = await tools.runShell(os.tmpdir(), 'printf hello-jarvis');
assert.equal(result.exitCode, 0);
assert.match(result.stdout, /hello-jarvis/);
});
test('formatShellResult puts command output in front of the exit code', () => {
assert.equal(tools.formatShellResult({ exitCode: 0, stdout: 'Linux 6.8\n', stderr: '' }), 'Linux 6.8\nexit 0');
assert.equal(tools.formatShellResult({ exitCode: 0, stdout: '', stderr: '' }), '(no output)\nexit 0');
});
test('Always allow wildcard matches later shell commands', () => {
const rules = permRules.addRule([], 'run_terminal_cmd', { command: '*' }, 'allow');
assert.equal(permRules.resolve(rules, 'run_terminal_cmd', { command: 'uname -a' }), 'allow');
assert.equal(permRules.resolve(rules, 'run_terminal_cmd', { command: 'ls -la /tmp' }), 'allow');
});
test('run_terminal_cmd description still exists for the model', () => {
const def = tools.SCHEMAS.find((item) => item.name === 'run_terminal_cmd');
assert.ok(def);
assert.match(def.description, /shell command/i);
});
test('Jarvis voice budget runs one shell then forces an answer', () => {
const budget = toolBudget.fromPayload({}, 'jarvis-qvac');
assert.equal(budget.maxShellCalls, 1);
assert.equal(budget.maxTurns, 6);
assert.equal(toolBudget.shouldSkipShell(budget), false);
toolBudget.markShell(budget);
assert.equal(budget.answerOnly, true);
assert.equal(toolBudget.shouldSkipShell(budget), true);
assert.match(toolBudget.skipShellMessage(), /already ran/i);
assert.equal(
toolBudget.lastToolText([{ role: 'tool', content: 'Static hostname: nest\nexit 0' }], 80),
'Static hostname: nest exit 0'
);
});
test('coding-agent origin does not cap shell chaining', () => {
const budget = toolBudget.fromPayload({}, 'local');
assert.equal(budget.maxShellCalls, 0);
toolBudget.markShell(budget);
toolBudget.markShell(budget);
assert.equal(toolBudget.shouldSkipShell(budget), false);
assert.equal(budget.answerOnly, false);
});
+34 -1
View File
@@ -7,13 +7,19 @@ import { pcmS16le } from '../daemon/voice-adapters.js';
import { WakeEngine } from '../daemon/wake-engine.js';
import { VadSegmenter } from '../daemon/vad.js';
import { PipeWireCapture, pcmRms } from '../daemon/audio-pipewire.js';
import { SentenceBuffer, isMeaningfulTranscript, isSpeakable } from '../daemon/transcript.js';
import { SentenceBuffer, isMeaningfulTranscript, isSpeakable, speakableForTts } from '../daemon/transcript.js';
test('Phase 4 transcript filtering and sentence buffering are deterministic', () => {
assert.equal(isMeaningfulTranscript('[BLANK_AUDIO]'), false);
assert.equal(isMeaningfulTranscript('hi'), false);
assert.equal(isSpeakable('OK.'), true);
assert.equal(isSpeakable('[object Object]'), false);
assert.equal(speakableForTts('Using `run_terminal_cmd`'), "Using 'run terminal cmd'");
assert.equal(speakableForTts('Your IP is 192.168.0.1'), 'Your I P is 192 168 0 1');
assert.equal(
speakableForTts('QVAC fetched https://example.com/ip'),
'Quantum Verse Automatic Computer fetched example dot com slash I P'
);
assert.equal(isMeaningfulTranscript('what time is it'), true);
const out = []; const buffer = new SentenceBuffer({ onSentence: (s) => out.push(s) });
buffer.push('First sentence. Second'); buffer.push(' sentence!'); buffer.flush();
@@ -71,6 +77,33 @@ test('empty TTS still leaves the daemon listening', async () => {
assert.equal(daemon.finished, true);
});
test('multi-sentence speech returns to listening once', async () => {
const daemon = new EventEmitter();
const states = [];
daemon.state = 'SPEAKING';
daemon.voice = {
finishSpeaking() {
daemon.finished = (daemon.finished || 0) + 1;
},
};
daemon.setState = (state) => { daemon.state = state; states.push(state); };
const capture = new EventEmitter(); capture.start = () => {}; capture.stop = () => {};
const wake = new WakeEngine({ detect: () => null });
const loop = new VoiceLoop({
daemon,
capture,
wake,
vad: new VadSegmenter(),
tts: { speak: async () => ({ samples: new Int16Array(2) }) },
playback: { play: async () => {}, stop() {} },
});
loop.status.tts = true;
await loop.speak('Hello there. How are you today?');
assert.equal(daemon.state, 'LISTENING');
assert.equal(daemon.finished, 1);
assert.deepEqual(states, ['LISTENING']);
});
test('PCM packing uses even s16le sample pairs', () => {
const buf = Buffer.alloc(4);
buf.writeInt16LE(256, 0);
+71 -13
View File
@@ -1,5 +1,8 @@
/**
* Context compaction: heuristic fallback + optional one-shot LLM summary.
*
* Tool schemas are reserved out of historyBudget. Compact against conversation
* tokens only — never treat a large tool list as "history is full".
*/
const truncate = require('./truncate.js');
@@ -7,6 +10,7 @@ const truncate = require('./truncate.js');
const CHAR_PER_TOKEN = 3;
const THRESHOLD = 0.68;
const MIN_SUMMARY = 80;
const MIN_COMPACT_MESSAGES = 4;
const COMPACT_PROMPT =
'Summarize this coding-agent conversation. Use exactly these sections:\n' +
'1. Goal\n' +
@@ -15,6 +19,13 @@ const COMPACT_PROMPT =
'4. Open work\n' +
'5. Next action\n' +
'Be specific (paths, names, errors). Do not say the conversation was compacted.';
const VOICE_COMPACT_PROMPT =
'Summarize this spoken assistant conversation for the next turn. Use exactly these sections:\n' +
'1. Latest user request\n' +
'2. Facts from tools\n' +
'3. What was already answered\n' +
'4. Open follow-ups\n' +
'Be specific. Do not greet. Do not say the conversation was compacted.';
function contentChars(content) {
if (content == null) return 0;
@@ -42,23 +53,38 @@ function messageChars(m) {
return n + 8;
}
function estimateTokens(messages, tools) {
function nonSystemCount(messages) {
let n = 0;
for (const m of messages || []) {
if (m && m.role !== 'system') n += 1;
}
return n;
}
function conversationTokens(messages) {
let n = 0;
for (const m of messages || []) n += messageChars(m);
n += JSON.stringify(tools || []).length;
return Math.ceil(n / CHAR_PER_TOKEN);
}
function toolTokens(tools) {
return Math.ceil(JSON.stringify(tools || []).length / CHAR_PER_TOKEN);
}
function estimateTokens(messages, tools) {
return conversationTokens(messages) + toolTokens(tools);
}
function historyBudget(ctxSize, tools, attempt) {
const cap = ctxSize > 0 ? Number(ctxSize) : 8192;
const toolTok = Math.ceil(JSON.stringify(tools || []).length / CHAR_PER_TOKEN);
const toolTok = toolTokens(tools);
const reserve = Math.max(384, Math.floor(cap * (0.18 + (Number(attempt) || 0) * 0.08)));
return Math.max(240, Math.floor(cap * 0.72) - toolTok - reserve);
}
function shouldCompact(messages, tools, ctxSize) {
const cap = ctxSize > 0 ? ctxSize : 8192;
return estimateTokens(messages, tools) > Math.floor(cap * THRESHOLD);
if (nonSystemCount(messages) < MIN_COMPACT_MESSAGES) return false;
return conversationTokens(messages) > historyBudget(ctxSize, tools, 0);
}
function isOverflowError(err) {
@@ -153,23 +179,30 @@ function lastRealUserIndex(list) {
return -1;
}
function overHistoryBudget(keep, budget) {
return conversationTokens(keep) > budget;
}
function heuristicCompact(messages, opts) {
opts = opts || {};
const budget = opts.budgetTokens || 6000;
const aggressive = !!opts.aggressive;
if (!aggressive && nonSystemCount(messages) < MIN_COMPACT_MESSAGES) {
return (messages || []).slice();
}
let keep = messages.slice();
while (estimateTokens(keep, opts.tools) > budget && keep.length > 4) {
while (overHistoryBudget(keep, budget) && keep.length > 4) {
let idx = keep.findIndex((m, i) => i > 0 && m.role === 'tool');
if (idx < 0) idx = keep.findIndex((m, i) => i > 1 && m.role === 'assistant');
if (idx < 0) break;
keep.splice(idx, 1);
}
const maxMsg = aggressive ? 1200 : 3200;
if (estimateTokens(keep, opts.tools) > budget) {
if (overHistoryBudget(keep, budget)) {
const lastUser = lastRealUserIndex(keep);
keep = keep.map((m, i) => (i === 0 || i === lastUser ? m : truncateMsg(m, maxMsg)));
}
if (estimateTokens(keep, opts.tools) > budget && keep.length > 3) {
if (overHistoryBudget(keep, budget) && keep.length > 3) {
const head = keep[0];
const lastUserIdx = lastRealUserIndex(keep);
const lastUser = lastUserIdx >= 0 ? keep[lastUserIdx] : null;
@@ -183,7 +216,7 @@ function heuristicCompact(messages, opts) {
keep = keep.concat(tail);
keep = keep.map((m, i) => (i === 0 ? m : truncateMsg(m, aggressive ? 700 : 1800)));
}
while (estimateTokens(keep, opts.tools) > budget && keep.length > 3) {
while (overHistoryBudget(keep, budget) && keep.length > 3) {
const dropAt = keep.findIndex((m, i) => i > 1 && !isRealUser(m));
if (dropAt < 0) break;
keep.splice(dropAt, 1);
@@ -205,7 +238,12 @@ function transcript(messages) {
.join('\n\n');
}
function autoContinue(messages) {
function isVoice(opts) {
return !!(opts && opts.voice);
}
function autoContinue(messages, opts) {
if (isVoice(opts)) return null;
const list = messages || [];
const last = list[list.length - 1];
if (!last) return null;
@@ -219,7 +257,14 @@ function autoContinue(messages) {
return null;
}
function compactReminder() {
function compactReminder(opts) {
if (isVoice(opts)) {
return (
'<system-reminder>\n' +
'Context was compacted. Trust the summary and the last user request. Answer that request. Do not greet again.\n' +
'</system-reminder>'
);
}
return (
'<system-reminder>\n' +
'Context was compacted. Trust the summary and the last user request. Re-read files before further edits. Follow AGENTS.md if present.\n' +
@@ -230,17 +275,25 @@ function compactReminder() {
async function compactWithLlm(messages, opts) {
opts = opts || {};
const fallback = () => heuristicCompact(messages, opts);
if (!opts.aggressive && nonSystemCount(messages) < MIN_COMPACT_MESSAGES) {
return (messages || []).slice();
}
const complete = opts.complete;
if (typeof complete !== 'function') return fallback();
const voice = isVoice(opts);
const cap =
opts.maxTranscriptChars ||
Math.max(1500, Math.min(24000, Math.floor((opts.budgetTokens || 4000) * CHAR_PER_TOKEN * 0.45)));
const body = truncate.truncateWithMarker(transcript(messages), cap);
const prompt = voice ? VOICE_COMPACT_PROMPT : COMPACT_PROMPT;
const sys = voice
? 'Reply with the four summary sections only. No tools. Do not greet.'
: 'Reply with the five summary sections only. No tools.';
try {
const result = await complete({
history: [
{ role: 'system', content: 'Reply with the five summary sections only. No tools.' },
{ role: 'user', content: COMPACT_PROMPT + '\n\n---\n\n' + body },
{ role: 'system', content: sys },
{ role: 'user', content: prompt + '\n\n---\n\n' + body },
],
tools: [],
});
@@ -256,10 +309,15 @@ module.exports = {
CHAR_PER_TOKEN,
THRESHOLD,
MIN_SUMMARY,
MIN_COMPACT_MESSAGES,
COMPACT_PROMPT,
VOICE_COMPACT_PROMPT,
conversationTokens,
toolTokens,
estimateTokens,
historyBudget,
shouldCompact,
nonSystemCount,
isOverflowError,
usage,
snapshot,
+85 -32
View File
@@ -15,6 +15,7 @@ const toolSet = require('./tool-set.js');
const planMode = require('./plan-mode.js');
const todos = require('./todos.js');
const stationarity = require('./stationarity.js');
const toolBudget = require('./tool-budget.js');
const goalMod = require('./goal.js');
const truncate = require('./truncate.js');
const sr = require('./search-replace.js');
@@ -160,7 +161,18 @@ function resolvePermission(jobId, toolCallId, decision) {
if (fn) {
pendingPerms.delete(key);
fn(decision);
return true;
}
const keys = Array.from(pendingPerms.keys());
for (const k of keys) {
if (k === toolCallId || k.endsWith(':' + toolCallId)) {
const resolve = pendingPerms.get(k);
pendingPerms.delete(k);
if (resolve) resolve(decision);
return true;
}
}
return false;
}
function waitCustomResult(jobId, toolCallId) {
@@ -260,11 +272,15 @@ function applyPlanWrite(session, name, args) {
return { ok: true, path: file, bytes: text.length };
}
function sidecarMessages(session, tracker) {
function sidecarMessages(session, tracker, budget) {
const extra = [];
if (budget && budget.answerOnly) {
extra.push({ role: 'user', content: toolBudget.answerNowMessage() });
}
if (tracker && tracker.pendingCompactReminder) {
extra.push({ role: 'user', content: compaction.compactReminder() });
const cont = compaction.autoContinue(session.history);
const voice = !!(budget && budget.voice);
extra.push({ role: 'user', content: compaction.compactReminder({ voice }) });
const cont = compaction.autoContinue(session.history, { voice });
if (cont) extra.push(cont);
tracker.pendingCompactReminder = false;
}
@@ -368,6 +384,7 @@ async function runTurn(ctx) {
await ensureModel(session.model);
const voice = (payload && payload.voice === true) || origin === 'jarvis-qvac';
let extraSys = payload && payload.system;
if (session.goal && goalMod.isActive(session.goal)) {
extraSys = [extraSys, goalMod.plannerAddendum(session.goal)].filter(Boolean).join('\n\n');
@@ -376,20 +393,24 @@ async function runTurn(ctx) {
cwd: hostWorkspace ? cwd : session.workspace || cwd,
hostWorkspace,
extra: extraSys,
fsRead: hostWorkspace
? (c, r) => {
try {
return fsRead(c, r);
} catch (_) {
return '';
personality: voice ? 'voice' : undefined,
fsRead:
hostWorkspace && !voice
? (c, r) => {
try {
return fsRead(c, r);
} catch (_) {
return '';
}
}
}
: null,
: null,
});
const sidecars = [];
if (hostWorkspace) {
const gitText = await gitSidecar.gitStatusSb(cwd, { hostWorkspace: true, run: tools.runShell });
if (gitText) sidecars.push(gitText);
if (!voice) {
const gitText = await gitSidecar.gitStatusSb(cwd, { hostWorkspace: true, run: tools.runShell });
if (gitText) sidecars.push(gitText);
}
const memText = memory.injectBlock(origin, userText || '');
if (memText) sidecars.push(memText);
}
@@ -416,6 +437,7 @@ async function runTurn(ctx) {
const cancelled = () => live.get(session.id) && live.get(session.id).cancelled;
const stuck = stationarity.create();
const budget = toolBudget.fromPayload(payload, origin);
let lastText = '';
let goalNudges = 0;
@@ -557,29 +579,39 @@ async function runTurn(ctx) {
}
try {
for (let turn = 0; turn < MAX_TURNS; turn++) {
for (let turn = 0; turn < budget.maxTurns; turn++) {
if (cancelled()) return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', turns: turn });
const toolDefs = buildToolDefs(session, payload, tracker);
if (turn === budget.maxTurns - 1) toolBudget.forceAnswer(budget);
const toolDefs = budget.answerOnly ? [] : buildToolDefs(session, payload, tracker);
ctx.planMode = planMode.isActive(tracker);
const ctxSize = loadedCtxSize();
const beforeLen = session.history.length;
const beforeUsage = compaction.usage(session.history, toolDefs, ctxSize);
emitLive(emit, session.id, jobId, Object.assign({ type: 'context' }, beforeUsage));
if (compaction.shouldCompact(session.history, toolDefs, ctxSize)) {
const useLlm = !budget.voice || compaction.nonSystemCount(session.history) >= 8;
emitUpdate(emit, session.id, jobId, {
type: 'compaction',
status: 'start',
method: 'llm',
method: useLlm ? 'llm' : 'heuristic',
used: beforeUsage.used,
limit: beforeUsage.limit,
pct: beforeUsage.pct,
threshold: beforeUsage.threshold,
});
session.history = await compaction.compactWithLlm(session.history, {
const compactOpts = {
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0),
tools: toolDefs,
complete: (opts) => engine.complete(Object.assign({}, opts, { desktopVision: false })),
});
voice: !!budget.voice,
};
session.history = useLlm
? await compaction.compactWithLlm(
session.history,
Object.assign({}, compactOpts, {
complete: (opts) => engine.complete(Object.assign({}, opts, { desktopVision: false })),
})
)
: compaction.compact(session.history, compactOpts);
sessions.replaceHistory(session.id, session.history);
tracker.pendingCompactReminder = true;
if (hostWorkspace) {
@@ -593,7 +625,7 @@ async function runTurn(ctx) {
emitUpdate(emit, session.id, jobId, {
type: 'compaction',
status: 'done',
method: 'llm',
method: useLlm ? 'llm' : 'heuristic',
used: afterUsage.used,
limit: afterUsage.limit,
pct: afterUsage.pct,
@@ -605,6 +637,7 @@ async function runTurn(ctx) {
session.history = compaction.compact(session.history, {
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0),
tools: toolDefs,
voice: !!budget.voice,
});
if (session.history.length !== beforeLen) {
sessions.replaceHistory(session.id, session.history);
@@ -624,7 +657,7 @@ async function runTurn(ctx) {
}
emitUpdate(emit, session.id, jobId, { type: 'turn', turn });
let streamBase = compaction.usage(session.history.concat(sidecarMessages(session, tracker)), toolDefs, ctxSize);
let streamBase = compaction.usage(session.history.concat(sidecarMessages(session, tracker, budget)), toolDefs, ctxSize);
emitLive(emit, session.id, jobId, Object.assign({ type: 'context' }, streamBase));
let streamChars = 0;
function liveUsed() {
@@ -636,7 +669,7 @@ async function runTurn(ctx) {
}
let result;
for (let overflowTry = 0; overflowTry < 4; overflowTry++) {
const history = session.history.concat(sidecarMessages(session, tracker));
const history = session.history.concat(sidecarMessages(session, tracker, budget));
streamBase = compaction.usage(history, toolDefs, ctxSize);
streamChars = 0;
try {
@@ -681,6 +714,7 @@ async function runTurn(ctx) {
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, overflowTry + 1),
tools: toolDefs,
aggressive: true,
voice: !!budget.voice,
});
sessions.replaceHistory(session.id, session.history);
tracker.pendingCompactReminder = true;
@@ -694,15 +728,17 @@ async function runTurn(ctx) {
Object.assign({ type: 'context' }, usageFromStats(result && result.stats, liveUsed(), ctxSize))
);
if (result.text) {
lastText = result.text;
pushHistory(session, { role: 'assistant', content: result.text });
let calls = (result && result.toolCalls) || [];
if (budget.answerOnly) calls = [];
if ((result && result.text) || calls.length) {
if (result.text) lastText = result.text;
const assistant = { role: 'assistant', content: result.text || '' };
if (calls.length) assistant.tool_calls = calls;
pushHistory(session, assistant);
}
const calls = result.toolCalls || [];
if (!calls.length) {
const goalActive = goalMod.isActive(session.goal);
if (goalActive && goalNudges < MAX_GOAL_NUDGES) {
if (goalActive && goalNudges < MAX_GOAL_NUDGES && !budget.answerOnly) {
goalNudges += 1;
pushHistory(session, { role: 'user', content: goalMod.continuation(session.goal) });
continue;
@@ -710,7 +746,7 @@ async function runTurn(ctx) {
return endTurn(emit, session, jobId, tracker, {
type: 'end',
reason: 'stop',
text: result.text || lastText || '',
text: (result && result.text) || lastText || toolBudget.lastToolText(session.history) || '',
turns: turn + 1,
});
}
@@ -738,6 +774,13 @@ async function runTurn(ctx) {
continue;
}
if (name === 'run_terminal_cmd' && toolBudget.shouldSkipShell(budget)) {
const skipped = toolBudget.skipShellMessage();
pushHistory(session, { role: 'tool', name, content: skipped, tool_call_id: toolCallId });
emitUpdate(emit, session.id, jobId, { type: 'tool_result', toolCallId, name, result: skipped });
continue;
}
if (sandbox.needsPermission(name, mode) && !planMode.isPlanFilePath(args.path, tracker.planPath)) {
let remembered = null;
try {
@@ -760,7 +803,7 @@ async function runTurn(ctx) {
let decision = await waitPermission(jobId, { toolCallId });
if (decision === 'always') {
try {
permStore.remember(name, args, 'allow');
permStore.rememberAlways(name);
} catch (_) {}
decision = 'allow';
}
@@ -774,6 +817,7 @@ async function runTurn(ctx) {
}
prepared.push({ name, args, toolCallId });
if (name === 'run_terminal_cmd') toolBudget.markShell(budget);
}
let stopEarly = null;
@@ -795,19 +839,28 @@ async function runTurn(ctx) {
}
if (stopEarly) break;
}
if (prepared.length) toolBudget.markToolRound(budget);
if (stopEarly) {
return endTurn(emit, session, jobId, tracker, stopEarly);
}
if (stuckNow) {
return endTurn(emit, session, jobId, tracker, { reason: 'stuck', text: lastText, turns: turn + 1 });
return endTurn(emit, session, jobId, tracker, {
reason: 'stuck',
text: lastText || toolBudget.lastToolText(session.history),
turns: turn + 1,
});
}
if (stationarity.shouldNudge(stuck)) {
stationarity.markNudged(stuck);
pushHistory(session, { role: 'user', content: stationarity.nudgeText() });
}
}
return endTurn(emit, session, jobId, tracker, { reason: 'max_turns', text: lastText, turns: MAX_TURNS });
return endTurn(emit, session, jobId, tracker, {
reason: 'max_turns',
text: lastText || toolBudget.lastToolText(session.history),
turns: budget.maxTurns,
});
} catch (err) {
if (err && err.message === 'cancelled') {
return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', text: lastText });
+6 -1
View File
@@ -34,4 +34,9 @@ function remember(tool, args, decision) {
return save(rules.addRule(load(), tool, args, decision));
}
module.exports = { rulesFile, load, save, resolve, remember };
function rememberAlways(tool) {
const args = String(tool) === 'run_terminal_cmd' ? { command: '*' } : { path: '*' };
return remember(tool, args, 'allow');
}
module.exports = { rulesFile, load, save, resolve, remember, rememberAlways };
+7 -1
View File
@@ -29,7 +29,13 @@ function loadWorkspaceRules(fsRead, cwd) {
return chunks.join('\n\n');
}
function assemble({ cwd, extra, fsRead, hostWorkspace }) {
function assemble({ cwd, extra, fsRead, hostWorkspace, personality }) {
if (personality === 'voice') {
const parts = [];
if (extra) parts.push(String(extra));
if (cwd) parts.push('Current workspace: ' + cwd);
return parts.join('\n\n');
}
const parts = [hostWorkspace === false ? PAGE_SYSTEM : DEFAULT_SYSTEM];
if (cwd) parts.push(hostWorkspace === false ? 'Workspace: ' + cwd : 'Current workspace: ' + cwd);
const rules = hostWorkspace === false ? '' : fsRead ? loadWorkspaceRules(fsRead, cwd) : '';
+82
View File
@@ -0,0 +1,82 @@
/**
* Voice/coding turn budgets. No Bare imports.
*
* Coding agents may chain many shells. A spoken GNOME assistant should run one
* command, then answer — otherwise a 4B model loops hostnamectl/uname/free.
*/
function num(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
}
function fromPayload(payload, origin) {
payload = payload || {};
const voice = payload.voice === true || origin === 'jarvis-qvac';
const unlimitedShell = payload.maxShellCalls === 0 || payload.maxShellCalls === false;
return {
voice,
maxTurns: num(payload.maxTurns, voice ? 6 : 24),
maxShellCalls: unlimitedShell ? 0 : num(payload.maxShellCalls, voice ? 1 : 0),
maxToolRounds: num(payload.maxToolRounds, voice ? 4 : 0),
shellCalls: 0,
toolRounds: 0,
answerOnly: false,
};
}
function capped(limit) {
return limit > 0;
}
function shouldSkipShell(budget) {
return !!(budget && capped(budget.maxShellCalls) && budget.shellCalls >= budget.maxShellCalls);
}
function markShell(budget) {
if (!budget) return;
budget.shellCalls += 1;
if (shouldSkipShell(budget)) budget.answerOnly = true;
}
function markToolRound(budget) {
if (!budget) return;
budget.toolRounds += 1;
if (capped(budget.maxToolRounds) && budget.toolRounds >= budget.maxToolRounds) budget.answerOnly = true;
}
function forceAnswer(budget) {
if (budget) budget.answerOnly = true;
}
function skipShellMessage() {
return 'A terminal command already ran this turn. Answer the user from that output. Do not run another command.';
}
function answerNowMessage() {
return 'You have tool results. Reply to the user in one to three sentences. Do not call more tools.';
}
function lastToolText(history, maxChars) {
const list = Array.isArray(history) ? history : [];
for (let i = list.length - 1; i >= 0; i--) {
if (list[i] && list[i].role === 'tool' && list[i].content) {
const text = String(list[i].content).replace(/\s+/g, ' ').trim();
if (!text) continue;
const max = maxChars > 0 ? maxChars : 400;
return text.length > max ? text.slice(0, max) + '…' : text;
}
}
return '';
}
module.exports = {
fromPayload,
shouldSkipShell,
markShell,
markToolRound,
forceAnswer,
skipShellMessage,
answerNowMessage,
lastToolText,
};
+52 -19
View File
@@ -148,6 +148,30 @@ async function rgGrep(root, pattern, glob, timeoutMs) {
});
}
function collectStream(stream, maxChars) {
let buf = '';
if (!stream) return () => buf;
const append = (chunk) => {
buf += Buffer.isBuffer(chunk) || chunk instanceof Uint8Array ? Buffer.from(chunk).toString() : String(chunk);
if (buf.length > maxChars) buf = buf.slice(-maxChars);
};
if (typeof stream.on === 'function') stream.on('data', append);
if (typeof stream.resume === 'function') stream.resume();
return () => buf;
}
function formatShellResult(result) {
const exitCode = result && result.exitCode != null ? result.exitCode : 0;
const stdout = String((result && result.stdout) || '').trimEnd();
const stderr = String((result && result.stderr) || '').trimEnd();
const parts = [];
if (stdout) parts.push(stdout);
if (stderr) parts.push(stderr);
if (!parts.length) parts.push('(no output)');
parts.push('exit ' + String(exitCode));
return parts.join('\n');
}
async function runShell(cwd, command, timeoutMs) {
let spawn;
try {
@@ -160,22 +184,28 @@ async function runShell(cwd, command, timeoutMs) {
const args = isWin ? ['/c', command] : ['-c', command];
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 200000) stdout = stdout.slice(-200000); });
if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 80000) stderr = stderr.slice(-80000); });
const readOut = collectStream(proc.stdout, 200000);
const readErr = collectStream(proc.stderr, 80000);
let settled = false;
const t = setTimeout(() => {
try { proc.kill(); } catch (_) {}
reject(new Error('command timed out'));
finish(new Error('command timed out'));
}, timeoutMs || 30000);
proc.on('exit', (code) => {
const finish = (err, code) => {
if (settled) return;
settled = true;
clearTimeout(t);
resolve({ exitCode: code, stdout, stderr });
});
proc.on('error', (err) => {
clearTimeout(t);
reject(err);
});
if (err) reject(err);
else resolve({ exitCode: code, stdout: readOut(), stderr: readErr() });
};
// Bare's subprocess emits `exit` before it resumes stdio pipes. Wait for
// `close` so stdout/stderr are actually collected.
if (typeof proc.on === 'function') {
proc.on('close', (code) => finish(null, code));
proc.on('error', (err) => finish(err));
} else {
finish(new Error('spawned process has no event API'));
}
});
}
@@ -188,7 +218,7 @@ const SCHEMAS = [
{ type: 'function', name: 'run_terminal_cmd', description: 'Run a shell command in the workspace cwd.', parameters: { type: 'object', properties: { command: { type: 'string' }, timeout_ms: { type: 'number' } }, required: ['command'] } },
{ type: 'function', name: 'todo_write', description: 'Merge or replace session todos. Status: pending | in_progress | completed | cancelled.', parameters: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'cancelled'] } } } }, merge: { type: 'boolean', description: 'If true (default), merge by id. If false, replace the list.' } }, required: ['todos'] } },
{ type: 'function', name: 'web_search', description: 'Search the public web (DuckDuckGo HTML).', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } },
{ type: 'function', name: 'web_fetch', description: 'Fetch a public http(s) URL as text. Off unless enabled.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
{ type: 'function', name: 'web_fetch', description: 'Fetch any http or https URL as text, including public internet hosts.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
{ type: 'function', name: 'memory_search', description: 'Search local agent memory notes.', parameters: { type: 'object', properties: { query: { type: 'string' } } } },
{ type: 'function', name: 'memory_get', description: 'Read a memory note by name.', parameters: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
{ type: 'function', name: 'memory_write', description: 'Write a local agent memory note.', parameters: { type: 'object', properties: { name: { type: 'string' }, text: { type: 'string' } }, required: ['name', 'text'] } },
@@ -226,7 +256,7 @@ async function webSearch(query) {
async function webFetch(url) {
const net = require('../lib/net.js');
net.assertPublicHttpUrl(url);
net.assertHttpUrl(url);
const res = await fetch(url, { headers: { 'user-agent': 'agent-harness/0.1' } });
let text = await res.text();
text = truncate.truncateWithMarker(text, 80000);
@@ -306,10 +336,13 @@ async function execute(ctx, name, args) {
}
case 'run_terminal_cmd': {
if (!sandbox.isAllowed(origin, cwd)) throw new Error('cwd not allowlisted');
if (!sandbox.shellSafe(args.command)) {
throw new Error('command not allowlisted (or contains shell metacharacters)');
}
return runShell(cwd, args.command, args.timeout_ms || args.timeoutMs);
const command = String(args.command || '').trim();
if (!command) throw new Error('command required');
// HUD/CLI permission is the gate. The coding-agent allowlist would reject
// desktop commands the user already approved (and Bare would then look
// like a silent empty result).
const raw = await runShell(cwd, command, args.timeout_ms || args.timeoutMs);
return formatShellResult(raw);
}
case 'todo_write': {
const mode = args.merge === false || args.replace === true ? 'replace' : 'merge';
@@ -392,4 +425,4 @@ function ensureParent(abs) {
} catch (_) {}
}
module.exports = { defs, execute, SCHEMAS, HOST_WORKSPACE_TOOLS: toolSet.HOST_WORKSPACE_TOOLS, runShell };
module.exports = { defs, execute, SCHEMAS, HOST_WORKSPACE_TOOLS: toolSet.HOST_WORKSPACE_TOOLS, runShell, formatShellResult };
+4
View File
@@ -40,6 +40,10 @@ function wrapSession(summary, opts) {
permissionMode: opts.permissionMode || 'ask',
webFetch: opts.webFetch === true,
system: opts.system,
maxTurns: opts.maxTurns,
maxShellCalls: opts.maxShellCalls,
maxToolRounds: opts.maxToolRounds,
voice: opts.voice,
},
payload || {}
);
+7 -2
View File
@@ -32,7 +32,7 @@ function isBlockedHostname(hostname) {
return false;
}
function assertPublicHttpUrl(raw) {
function assertHttpUrl(raw) {
let url;
try {
url = new URL(String(raw));
@@ -42,10 +42,15 @@ function assertPublicHttpUrl(raw) {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('only http(s) URLs are allowed');
}
return url;
}
function assertPublicHttpUrl(raw) {
const url = assertHttpUrl(raw);
if (isBlockedHostname(url.hostname)) {
throw new Error('private, loopback, and metadata hosts are blocked');
}
return url;
}
module.exports = { isBlockedHostname, assertPublicHttpUrl };
module.exports = { isBlockedHostname, assertHttpUrl, assertPublicHttpUrl };
+74 -1
View File
@@ -17,6 +17,7 @@ const stationarity = require('../agent/stationarity.js');
const truncate = require('../agent/truncate.js');
const permRules = require('../agent/perm-rules.js');
const policy = require('../agent/policy.js');
const toolBudget = require('../agent/tool-budget.js');
const paths = require('../lib/paths.js');
const device = require('../lib/device.js');
@@ -35,6 +36,14 @@ function testCatalog() {
assert.ok(listed && listed.label.indexOf('~3.5 GB') >= 0);
}
function hugeToolDefs() {
return Array.from({ length: 24 }, (_, i) => ({
name: 'tool_' + i,
description: 'd'.repeat(400),
parameters: { type: 'object', properties: { q: { type: 'string' } } },
}));
}
function testCompaction() {
const sys = { role: 'system', content: 'sys' };
const user = { role: 'user', content: 'hello' };
@@ -48,6 +57,39 @@ function testCompaction() {
assert.ok(out.length < hist.length);
assert.strictEqual(out[0].role, 'system');
assert.ok(compaction.isOverflowError(new Error('prompt too long for context window')));
const tools = hugeToolDefs();
const short = [
{ role: 'system', content: 'You are Jarvis' },
{ role: 'assistant', content: 'Hello! How can I help you today?' },
{ role: 'user', content: 'Hi, please tell me about my computer.' },
];
assert.ok(compaction.toolTokens(tools) > 2000);
assert.equal(compaction.shouldCompact(short, tools, 8192), false);
const kept = compaction.compact(short, {
budgetTokens: compaction.historyBudget(8192, tools, 0),
tools,
});
assert.strictEqual(kept.length, short.length);
assert.strictEqual(kept[2].content, short[2].content);
assert.ok(JSON.stringify(kept).indexOf('Earlier turns were compacted') < 0);
const four = short.concat([
{ role: 'assistant', content: 'Let me look.' },
{ role: 'user', content: 'Go ahead.' },
]);
assert.ok(compaction.nonSystemCount(four) >= compaction.MIN_COMPACT_MESSAGES);
const stillKept = compaction.heuristicCompact(four, {
budgetTokens: compaction.historyBudget(8192, tools, 0),
tools,
});
assert.ok(stillKept.some((m) => String(m.content).indexOf('tell me about my computer') >= 0));
assert.ok(JSON.stringify(stillKept).indexOf('Earlier turns were compacted') < 0);
assert.strictEqual(compaction.autoContinue([{ role: 'assistant', content: 'hi' }], { voice: true }), null);
assert.ok(compaction.autoContinue([{ role: 'assistant', content: 'hi' }]));
assert.ok(compaction.compactReminder({ voice: true }).indexOf('Do not greet') >= 0);
assert.ok(compaction.VOICE_COMPACT_PROMPT.indexOf('Do not greet') >= 0);
}
function testSearchReplace() {
@@ -66,7 +108,17 @@ function testToolSet() {
];
const page = toolSet.filterBuiltinSchemas(all, { hostWorkspace: false, builtinTools: ['todo_write'] });
assert.ok(!page.find((t) => t.name === 'read_file'));
assert.ok(page.find((t) => t.name === 'todo_write'));
const withFetch = toolSet.filterBuiltinSchemas(all, {
hostWorkspace: true,
builtinTools: ['web_fetch'],
webFetch: true,
});
assert.ok(withFetch.find((t) => t.name === 'web_fetch'));
const noFetch = toolSet.filterBuiltinSchemas(all, {
hostWorkspace: true,
builtinTools: ['web_fetch'],
});
assert.ok(!noFetch.find((t) => t.name === 'web_fetch'));
}
function testPrompts() {
@@ -74,6 +126,18 @@ function testPrompts() {
assert.ok(prompts.DEFAULT_SYSTEM.indexOf('BridgeSwarm') < 0);
const page = prompts.assemble({ cwd: 'container', hostWorkspace: false });
assert.ok(page.indexOf('host filesystem') >= 0);
const voice = prompts.assemble({
personality: 'voice',
extra: 'You are Jarvis, a local Ubuntu GNOME voice assistant.',
cwd: '/tmp/jarvis',
hostWorkspace: true,
fsRead: () => '# AGENTS.md\nFollow coding-agent rules.',
});
assert.ok(voice.indexOf('You are Jarvis') >= 0);
assert.ok(voice.indexOf(prompts.DEFAULT_SYSTEM) < 0);
assert.ok(voice.indexOf('coding agent') < 0);
assert.ok(voice.indexOf('AGENTS.md') < 0);
assert.ok(voice.indexOf('Current workspace:') >= 0);
}
function testNet() {
@@ -81,6 +145,9 @@ function testNet() {
assert.throws(() => net.assertPublicHttpUrl('http://192.168.1.1/x'));
const u = net.assertPublicHttpUrl('https://example.com/a');
assert.strictEqual(u.hostname, 'example.com');
assert.strictEqual(net.assertHttpUrl('https://ifconfig.me/ip').hostname, 'ifconfig.me');
assert.strictEqual(net.assertHttpUrl('http://192.168.1.1/status').hostname, '192.168.1.1');
assert.throws(() => net.assertHttpUrl('file:///etc/passwd'));
}
function testCustomTools() {
@@ -124,6 +191,12 @@ function testTruncateAndPerm() {
assert.strictEqual(pat, 'git status');
assert.ok(policy.shellSafe('git status'));
assert.ok(!policy.shellSafe('rm -rf /'));
assert.ok(permRules.globish('uname -a', '*'));
assert.strictEqual(permRules.patternFromArgs('run_terminal_cmd', { command: '*' }), '*');
const voice = toolBudget.fromPayload({}, 'jarvis-qvac');
toolBudget.markShell(voice);
assert.strictEqual(voice.answerOnly, true);
assert.ok(toolBudget.shouldSkipShell(voice));
}
function testPaths() {