@@ -8,28 +8,13 @@ import Gio from 'gi://Gio';
|
|||||||
import GLib from 'gi://GLib';
|
import GLib from 'gi://GLib';
|
||||||
import St from 'gi://St';
|
import St from 'gi://St';
|
||||||
import Clutter from 'gi://Clutter';
|
import Clutter from 'gi://Clutter';
|
||||||
import Pango from 'gi://Pango';
|
|
||||||
import { installShellService } from './shell-dbus.js';
|
import { installShellService } from './shell-dbus.js';
|
||||||
|
import { ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel, coerceText, safeText, shortError } from './ui.js';
|
||||||
|
|
||||||
const BUS = 'io.qvac.Jarvis';
|
const BUS = 'io.qvac.Jarvis';
|
||||||
const PATH = '/io/qvac/Jarvis';
|
const PATH = '/io/qvac/Jarvis';
|
||||||
const IFACE = 'io.qvac.Jarvis.Session';
|
const IFACE = 'io.qvac.Jarvis.Session';
|
||||||
const OVERLAY_WIDTH = 720;
|
const GLYPHS = { ARMED: '◯', LISTENING: '◌', THINKING: '◉', SPEAKING: '◎', SLEEPING: '◐' };
|
||||||
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);
|
|
||||||
|
|
||||||
class JarvisProxy {
|
class JarvisProxy {
|
||||||
async connect() {
|
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 {
|
export default class JarvisExtension extends Extension {
|
||||||
enable() {
|
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._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._removeShellService = installShellService();
|
||||||
this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant';
|
this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant';
|
||||||
this._glyph = new St.Label({ text: '◯ Jarvis', style_class: 'jarvis-panel-glyph', y_align: Clutter.ActorAlign.CENTER }); this._glyph.accessible_name = 'Jarvis idle'; this._indicator.add_child(this._glyph);
|
this._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');
|
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._mountPopup();
|
||||||
this.overlay.onTalk = (pressed) => { if (pressed) { this._call('PushToTalk', '(b)', [true]); this._call('Arm'); } else { this._call('PushToTalk', '(b)', [false]); } };
|
this._indicator.connect('button-press-event', (_actor, event) => {
|
||||||
this.overlay.onStop = () => this._call('Cancel');
|
const button = event.get_button();
|
||||||
this.overlay.onReset = () => { this.overlay.clear(); this.overlay.setNotice('New conversation'); this._call('ResetContext'); };
|
if (button === 2) { this._call('Arm'); return Clutter.EVENT_STOP; }
|
||||||
this.overlay.onAsk = (text) => { this.overlay.addRow('U', text); this._call('Ask', '(s)', [text]); };
|
return Clutter.EVENT_PROPAGATE;
|
||||||
this.overlay.onMode = (mode) => this._call('SetMode', '(s)', [mode]);
|
});
|
||||||
this.overlay.onSuggestion = (id, payload) => this._call('Ask', '(s)', [payload?.text || payload?.prompt || id]);
|
this._keyName = 'hotkey';
|
||||||
this.overlay.onConfirm = (jobId, toolCallId, decision) => this._call('Confirm', '(sss)', [jobId, toolCallId, decision]);
|
try {
|
||||||
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}`); }
|
Main.wm.addKeybinding(this._keyName, this.settings, Meta.KeyBindingFlags.NONE, Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW, () => {
|
||||||
this._settingsChanged = this.settings.connect('changed::accent-color', () => this._applyAccent()); this._applyAccent(); this._connectDaemon(); this.overlay.show(true);
|
this._openPopup();
|
||||||
try { this._lockChanged = Main.screenShield.connect('locked-changed', () => { if (Main.screenShield.locked) { this.overlay.hide(); this._call('ComputerRevoke'); } }); } catch {}
|
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() {
|
async _connectDaemon() {
|
||||||
const proxy = this.proxy;
|
const proxy = this.proxy;
|
||||||
@@ -232,63 +175,96 @@ export default class JarvisExtension extends Extension {
|
|||||||
await proxy.connect(); if (this.proxy !== proxy) return;
|
await proxy.connect(); if (this.proxy !== proxy) return;
|
||||||
this._signals = [
|
this._signals = [
|
||||||
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
['StateChanged', (state) => { this._setState(state); this._refreshVoiceStatus(); }],
|
||||||
['ContextReset', () => { this.overlay.clear(); this.overlay.setNotice('New conversation'); }],
|
['ContextReset', () => { this._eachView((view) => { view.clear(); view.setNotice('New conversation'); }); }],
|
||||||
['WakeHeard', () => {}],
|
['WakeHeard', () => this.osd.showWake()],
|
||||||
['PartialTranscript', (text) => this.overlay.addRow('U', text)],
|
['PartialTranscript', (text) => this._eachView((view) => view.addRow('U', text))],
|
||||||
['Token', (text) => this.overlay.token(text)],
|
['Token', (text) => this._eachView((view) => view.token(text))],
|
||||||
['Thinking', (text) => this.overlay.updateThinking(text)],
|
['Thinking', (text) => this._eachView((view) => view.updateThinking(text))],
|
||||||
['ToolCall', (json) => this.overlay.addToolCall(json)],
|
['ToolCall', (json) => this._eachView((view) => view.addToolCall(json))],
|
||||||
['ToolResult', (json) => this.overlay.addToolResult(json)],
|
['ToolResult', (json) => this._eachView((view) => view.addToolResult(json))],
|
||||||
['Reply', (text) => this.overlay.finalizeReply(text)],
|
['Reply', (text) => this._eachView((view) => view.finalizeReply(text))],
|
||||||
['SpeakingLevel', (rms) => this.overlay.level(rms)],
|
['SpeakingLevel', () => {}],
|
||||||
['ListeningLevel', (rms) => this.overlay.level(rms)],
|
['ListeningLevel', () => {}],
|
||||||
['ChipOffered', (id, label, payload) => this.overlay.addChip(id, label, payload)],
|
['ChipOffered', (id, label, payload) => this._eachView((view) => view.addChip(id, label, payload))],
|
||||||
['JobProgress', (id, pct, label) => this.overlay.addJob(id, pct, label)],
|
['JobProgress', (id, pct, label) => this.cu.addJob(id, pct, label)],
|
||||||
['ComputerStep', (json) => this.overlay.addStep(json)],
|
['ComputerStep', (json) => { this.cu.addStep(json); this._eachView((view) => view.addStep(json)); }],
|
||||||
['ComputerHighlight', (json) => this.overlay.setTarget(json)],
|
['ComputerHighlight', (json) => this.cu.setTarget(json)],
|
||||||
['ConfirmationRequired', (tool, args, pattern) => this.overlay.offerConfirm(tool, args, pattern)],
|
['ConfirmationRequired', (tool, args, pattern) => { this._eachView((view) => view.offerConfirm(tool, args, pattern)); this._openPopup(); }],
|
||||||
['Error', (code, message) => { this.overlay.setNotice(message); this.overlay.finishThinking(); if (coerceText(code) === 'VOICE_UNAVAILABLE') this.overlay.setConnectionStatus('voice-unavailable'); }],
|
['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));
|
].map(([name, handler]) => proxy.on(name, handler));
|
||||||
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
|
proxy.onOwnerChanged = () => { if (this.proxy !== proxy) return; this._syncDaemon(); };
|
||||||
await 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() {
|
async _syncDaemon() {
|
||||||
if (!this.proxy) return;
|
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 {
|
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]);
|
this._setState(result.deep_unpack()[0]);
|
||||||
await this._refreshVoiceStatus();
|
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() {
|
async _refreshVoiceStatus() {
|
||||||
if (!this.proxy?.owned?.() || !this.overlay) return;
|
if (!this.proxy?.owned?.() || !this.popup) return;
|
||||||
try {
|
try {
|
||||||
const runtime = await this.proxy.call('GetRuntimeStatus');
|
const runtime = await this.proxy.call('GetRuntimeStatus');
|
||||||
if (!this.overlay) return;
|
if (!this.popup) return;
|
||||||
const status = JSON.parse(runtime.deep_unpack()[0] || '{}');
|
const status = JSON.parse(runtime.deep_unpack()[0] || '{}');
|
||||||
const voice = status.voice;
|
const voice = status.voice;
|
||||||
this.overlay.setConnectionStatus(voice ? 'local' : 'voice-unavailable');
|
this._eachView((view) => view.setConnectionStatus(voice ? 'local' : 'voice-unavailable'));
|
||||||
if (voice) {
|
if (voice) {
|
||||||
const input = voice.asr && voice.capture;
|
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._eachView((view) => view.setVoiceStatus({ tts: voice.tts, input, wake: voice.wake }));
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
} catch { this.overlay?.setConnectionStatus('voice-unavailable'); }
|
} catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); }
|
||||||
}
|
}
|
||||||
_call(name, signature, value) {
|
_call(name, signature, value) {
|
||||||
this.proxy.call(name, signature, value).catch((error) => {
|
this.proxy.call(name, signature, value).catch((error) => {
|
||||||
log(`Jarvis ${name}: ${error.message}`);
|
log(`Jarvis ${name}: ${error.message}`);
|
||||||
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
|
const fallback = name === 'ResetContext' ? 'Could not reset conversation' : `${name} failed: ${shortError(error.message)}`;
|
||||||
this.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); }
|
_setState(state) {
|
||||||
_applyAccent() { this.overlay.root.set_style(`--jarvis-accent: ${this.settings.get_string('accent-color')};`); }
|
const value = safeText(state);
|
||||||
_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; }); }
|
this._glyph.text = `${GLYPHS[value] || '◯'} Jarvis`;
|
||||||
_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(); }
|
this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`;
|
||||||
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; }
|
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]",
|
"uuid": "[email protected]",
|
||||||
"name": "Jarvis QVAC",
|
"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"],
|
"shell-version": ["46", "47", "48", "49", "50"],
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"session-modes": ["user"],
|
"session-modes": ["user"],
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import GLib from 'gi://GLib';
|
|||||||
import Gtk from 'gi://Gtk';
|
import Gtk from 'gi://Gtk';
|
||||||
|
|
||||||
const BIND = Gio.SettingsBindFlags.DEFAULT;
|
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 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 COMPUTER_MODES = ['off', 'observe', 'act'];
|
||||||
const MODEL_PROFILES = ['laptop-8gb', 'laptop-16gb', 'desktop-gpu'];
|
const MODEL_PROFILES = ['laptop-8gb', 'laptop-16gb', 'desktop-gpu'];
|
||||||
|
|
||||||
@@ -61,6 +64,75 @@ function comboRow(settings, title, subtitle, key, values) {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function callDaemon(name, signature, values, onDone) {
|
||||||
|
Gio.DBus.session.call(
|
||||||
|
BUS,
|
||||||
|
PATH,
|
||||||
|
IFACE,
|
||||||
|
name,
|
||||||
|
signature ? GLib.Variant.new(signature, values) : null,
|
||||||
|
null,
|
||||||
|
Gio.DBusCallFlags.NONE,
|
||||||
|
4000,
|
||||||
|
null,
|
||||||
|
(_source, result) => {
|
||||||
|
try {
|
||||||
|
const reply = Gio.DBus.session.call_finish(result);
|
||||||
|
onDone?.(null, reply);
|
||||||
|
} catch (error) {
|
||||||
|
onDone?.(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantRow() {
|
||||||
|
const row = new Adw.ActionRow({
|
||||||
|
title: 'Desktop grant',
|
||||||
|
subtitle: 'Off. Jarvis cannot click or type until you allow it here.',
|
||||||
|
});
|
||||||
|
const allow = new Gtk.Button({ label: 'Allow now', valign: Gtk.Align.CENTER });
|
||||||
|
allow.add_css_class('suggested-action');
|
||||||
|
const revoke = new Gtk.Button({ label: 'Revoke', valign: Gtk.Align.CENTER });
|
||||||
|
revoke.add_css_class('destructive-action');
|
||||||
|
const refresh = () => {
|
||||||
|
callDaemon('ComputerStatus', null, null, (error, reply) => {
|
||||||
|
if (error) {
|
||||||
|
row.subtitle = 'Jarvis daemon is unavailable. Start jarvisd, then try Allow now.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let status = {};
|
||||||
|
try {
|
||||||
|
const unpacked = reply.deep_unpack?.() ?? reply.unpack?.();
|
||||||
|
const raw = Array.isArray(unpacked) ? unpacked[0] : unpacked;
|
||||||
|
status = JSON.parse(String(raw || '{}'));
|
||||||
|
} catch {}
|
||||||
|
if (status.active) {
|
||||||
|
row.subtitle = `Active. ${Number(status.steps_used) || 0} of ${Number(status.steps_max) || 20} steps used.`;
|
||||||
|
} else {
|
||||||
|
row.subtitle = 'Off. Press Allow now so Jarvis can observe or control the desktop for three minutes.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
allow.connect('clicked', () => {
|
||||||
|
callDaemon('ComputerGrant', '(b)', [true], (error) => {
|
||||||
|
row.subtitle = error ? `Could not grant: ${error.message}` : 'Grant requested. A portal prompt may appear.';
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
revoke.connect('clicked', () => {
|
||||||
|
callDaemon('ComputerRevoke', null, null, (error) => {
|
||||||
|
row.subtitle = error ? `Could not revoke: ${error.message}` : 'Grant revoked.';
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.add_suffix(allow);
|
||||||
|
row.add_suffix(revoke);
|
||||||
|
row.activatable_widget = allow;
|
||||||
|
refresh();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
export default class JarvisPreferences extends ExtensionPreferences {
|
export default class JarvisPreferences extends ExtensionPreferences {
|
||||||
fillPreferencesWindow(window) {
|
fillPreferencesWindow(window) {
|
||||||
window.set_title('Jarvis QVAC');
|
window.set_title('Jarvis QVAC');
|
||||||
@@ -79,13 +151,14 @@ export default class JarvisPreferences extends ExtensionPreferences {
|
|||||||
const desktopGroup = new Adw.PreferencesGroup({ title: 'Overlay and shortcuts' });
|
const desktopGroup = new Adw.PreferencesGroup({ title: 'Overlay and shortcuts' });
|
||||||
desktopGroup.add(strvRow(settings, 'Hotkey', 'hotkey'));
|
desktopGroup.add(strvRow(settings, 'Hotkey', 'hotkey'));
|
||||||
desktopGroup.add(entryRow(settings, 'Accent color', 'accent-color'));
|
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'));
|
desktopGroup.add(switchRow(settings, 'Confirm destructive actions', 'Ask before write, delete, or computer-use changes', 'confirm-destructive'));
|
||||||
desktop.add(desktopGroup);
|
desktop.add(desktopGroup);
|
||||||
|
|
||||||
const computer = new Adw.PreferencesPage({ title: 'Computer use', name: 'computer' });
|
const computer = new Adw.PreferencesPage({ title: 'Computer use', name: 'computer' });
|
||||||
const computerGroup = new Adw.PreferencesGroup({ title: 'Desktop control' });
|
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(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));
|
computerGroup.add(comboRow(settings, 'Privacy mode', 'How long Jarvis keeps the microphone open after wake', 'privacy-mode', PRIVACY_MODES));
|
||||||
computer.add(computerGroup);
|
computer.add(computerGroup);
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<key name="voice-id" type="s"><default>''</default></key>
|
<key name="voice-id" type="s"><default>''</default></key>
|
||||||
<key name="language" type="s"><default>'en-US'</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="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="confirm-destructive" type="b"><default>true</default></key>
|
||||||
<key name="computer-use-mode" type="s"><default>'off'</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>
|
<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-panel-glyph { color: var(--jarvis-accent, #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-menu { max-width: 360px; }
|
||||||
.jarvis-arc-header { spacing: 8px; }
|
.jarvis-menu-item { padding: 0; }
|
||||||
.jarvis-title { font-weight: bold; letter-spacing: 1px; font-size: 18px; }
|
.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-local { color: var(--jarvis-accent, #F4B942); font-size: 11px; }
|
||||||
.jarvis-status-line { color: #aeb6c8; font-size: 12px; }
|
.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 { 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-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-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-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-confirm-label { color: #f6f7fb; font-size: 13px; }
|
||||||
.jarvis-job, .jarvis-target { color: #4FD2FF; font-size: 12px; }
|
.jarvis-popup-scroll { height: 140px; }
|
||||||
.jarvis-agent-cursor { color: var(--jarvis-accent, #F4B942); font-size: 22px; }
|
.jarvis-session-scroll { height: 220px; }
|
||||||
.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-transcript { spacing: 6px; }
|
.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-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-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-row-tool { background-color: transparent; color: #9aa8c0; font-size: 12px; padding: 2px 6px; }
|
||||||
.jarvis-chip-scroll { height: 36px; }
|
.jarvis-chip-scroll { height: 32px; }
|
||||||
.jarvis-chips { spacing: 6px; }
|
.jarvis-chips { spacing: 6px; }
|
||||||
.jarvis-chip { padding: 5px 9px; border-radius: 999px; background-color: rgba(255, 255, 255, .08); }
|
.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: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 { 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-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-talk:active { background-color: #ffe09a; }
|
||||||
.jarvis-controls { spacing: 8px; }
|
.jarvis-controls { spacing: 6px; }
|
||||||
.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-settings { font-size: 12px; padding: 4px 8px; }
|
||||||
.jarvis-arc StEntry:focus { border-color: #F4B942; }
|
.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(); }
|
||||||
|
}
|
||||||
@@ -11,7 +11,28 @@ import { createPhase9GatewayTool } from '../skills/phase9-tools.js';
|
|||||||
export class HarnessBridge extends EventEmitter {
|
export class HarnessBridge extends EventEmitter {
|
||||||
constructor({ cwd = process.cwd(), model = 'qwen3.5-4b', tools = [], computer, observer, actuator, permissionMode = 'ask' } = {}) {
|
constructor({ cwd = process.cwd(), model = 'qwen3.5-4b', tools = [], computer, observer, actuator, permissionMode = 'ask' } = {}) {
|
||||||
super();
|
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;
|
this.session = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -102,21 +102,24 @@ export class JarvisDaemon extends EventEmitter {
|
|||||||
if (this.state === 'SPEAKING') this.setState('LISTENING');
|
if (this.state === 'SPEAKING') this.setState('LISTENING');
|
||||||
}
|
}
|
||||||
_speakReply(spoken) {
|
_speakReply(spoken) {
|
||||||
const play = async () => {
|
if (!this.voiceLoop) {
|
||||||
await this.ensureTts();
|
|
||||||
const playing = this.voiceLoop?.speak?.(spoken);
|
|
||||||
if (!playing) {
|
|
||||||
const reason = this.voiceLoop?.status?.errors?.tts;
|
|
||||||
if (reason && reason !== 'Spoken replies are disabled') this.emit('Error', 'TTS', reason);
|
|
||||||
this._finishSpeech();
|
this._finishSpeech();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await playing;
|
const play = async () => {
|
||||||
|
await this.ensureTts();
|
||||||
|
if (!this.voiceLoop?.status?.tts) {
|
||||||
|
const reason = this.voiceLoop?.status?.errors?.tts;
|
||||||
|
if (reason && reason !== 'Spoken replies are disabled') this.emit('Error', 'TTS', reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.voiceLoop.speak(spoken);
|
||||||
};
|
};
|
||||||
Promise.resolve(play()).catch((error) => {
|
Promise.resolve(play())
|
||||||
|
.catch((error) => {
|
||||||
this.emit('Error', 'TTS', error.message);
|
this.emit('Error', 'TTS', error.message);
|
||||||
if (this.state === 'SPEAKING') this._finishSpeech();
|
})
|
||||||
});
|
.finally(() => this._finishSpeech());
|
||||||
}
|
}
|
||||||
async ensureTts() {
|
async ensureTts() {
|
||||||
if (!this.voiceLoop) return;
|
if (!this.voiceLoop) return;
|
||||||
|
|||||||
@@ -1,5 +1,62 @@
|
|||||||
export const MIN_UTTERANCE_CHARS = 3;
|
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) {
|
export function isSpeakable(text) {
|
||||||
const value = String(text || '').trim();
|
const value = String(text || '').trim();
|
||||||
if (!value || value === '[object Object]') return false;
|
if (!value || value === '[object Object]') return false;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class QvacVoiceAdapter extends EventEmitter {
|
|||||||
if (!this.ttsId) throw new Error('Speech output is unavailable');
|
if (!this.ttsId) throw new Error('Speech output is unavailable');
|
||||||
const sdk = await qvacSdk();
|
const sdk = await qvacSdk();
|
||||||
const samples = await withQvacMaster(async () => {
|
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?.buffer != null) return await result.buffer;
|
||||||
if (result?.bufferStream) {
|
if (result?.bufferStream) {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
|
|||||||
+30
-11
@@ -3,7 +3,7 @@ import { PipeWireCapture } from './audio-pipewire.js';
|
|||||||
import { PipeWirePlayback } from './audio-playback.js';
|
import { PipeWirePlayback } from './audio-playback.js';
|
||||||
import { WakeEngine } from './wake-engine.js';
|
import { WakeEngine } from './wake-engine.js';
|
||||||
import { VadSegmenter } from './vad.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';
|
import { VoiceMetrics } from './voice-metrics.js';
|
||||||
|
|
||||||
export const POST_PLAYBACK_COOLDOWN_MS = 400;
|
export const POST_PLAYBACK_COOLDOWN_MS = 400;
|
||||||
@@ -91,29 +91,48 @@ export class VoiceLoop extends EventEmitter {
|
|||||||
_releaseSpeaking() {
|
_releaseSpeaking() {
|
||||||
this.isSpeaking = false;
|
this.isSpeaking = false;
|
||||||
this.wake.resume();
|
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) {
|
async speak(text) {
|
||||||
const generation = this._generation || 0;
|
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();
|
this.metrics.reply();
|
||||||
const sentences = String(text).match(/[^.!?]+[.!?]+|[^.!?]+$/g)?.map((part) => part.trim()).filter(isSpeakable) || [];
|
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 () => {
|
this._speechQueue = this._speechQueue.catch(() => {}).then(async () => {
|
||||||
|
try {
|
||||||
if (generation !== this._generation) return;
|
if (generation !== this._generation) return;
|
||||||
for (const sentence of sentences) {
|
for (const sentence of sentences) {
|
||||||
if (generation !== this._generation) return;
|
if (generation !== this._generation) return;
|
||||||
await this.speakSentence(sentence);
|
await this.speakSentence(sentence, generation);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (generation === this._generation) this._releaseSpeaking();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return this._speechQueue;
|
return this._speechQueue;
|
||||||
}
|
}
|
||||||
async speakSentence(text) {
|
async speakSentence(text, generation) {
|
||||||
this.isSpeaking = true; this.wake.pause(); this.daemon?.emit('StateChanged', 'SPEAKING');
|
this.isSpeaking = true;
|
||||||
try { const audio = await this.tts.speak(text); await this.playback.play(audio.samples); try { this.daemon?.emit('SpeakingLevel', 0); } catch {} }
|
this.wake.pause();
|
||||||
finally {
|
try {
|
||||||
this.isSpeaking = false; this.cooldownUntil = this.now() + this.cooldownMs; this.wake.resume();
|
const audio = await this.tts.speak(speakableForTts(text));
|
||||||
if (this.daemon?.state === 'SPEAKING') { this.daemon.voice?.finishSpeaking?.(); this.daemon.setState?.('LISTENING'); }
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ case "${MODEL_PROFILE}" in
|
|||||||
laptop-8gb|laptop-16gb|desktop-gpu) ;;
|
laptop-8gb|laptop-16gb|desktop-gpu) ;;
|
||||||
*) echo "Unknown model profile: ${MODEL_PROFILE}" >&2; exit 2 ;;
|
*) echo "Unknown model profile: ${MODEL_PROFILE}" >&2; exit 2 ;;
|
||||||
esac
|
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_ENABLED=true
|
||||||
[[ "${TTS_CHOICE:-Y}" =~ ^[Nn]$ ]] && TTS_ENABLED=false
|
|
||||||
mkdir -p "${HOME}/.config/jarvis"
|
mkdir -p "${HOME}/.config/jarvis"
|
||||||
"${BARE}" packaging/write-config.js "${HOME}/.config/jarvis/config.json" "${WAKE_PHRASE}" "${MODEL_PROFILE}" "${TTS_ENABLED}"
|
"${BARE}" packaging/write-config.js "${HOME}/.config/jarvis/config.json" "${WAKE_PHRASE}" "${MODEL_PROFILE}" "${TTS_ENABLED}"
|
||||||
echo "Saved wake phrase: ${WAKE_PHRASE}; model profile: ${MODEL_PROFILE}; TTS: ${TTS_ENABLED}"
|
echo "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
|
fi
|
||||||
|
|
||||||
echo "[4/5] TTS preview"
|
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"
|
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."
|
echo "Daemon did not register io.qvac.Jarvis; preview skipped."
|
||||||
else
|
else
|
||||||
echo "TTS is disabled; preview skipped."
|
echo "TTS preview skipped; spoken replies remain enabled."
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
echo "[5/5] Typed smoke test"
|
echo "[5/5] Typed smoke test"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export function createRuntimeTools({ computer } = {}) {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: 'jarvis_status',
|
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: {} },
|
parameters: { type: 'object', properties: {} },
|
||||||
execute: () => ({
|
execute: () => ({
|
||||||
local: true,
|
local: true,
|
||||||
@@ -14,7 +14,7 @@ export function createRuntimeTools({ computer } = {}) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'cu_status',
|
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: {} },
|
parameters: { type: 'object', properties: {} },
|
||||||
execute: () => computer?.status?.() || { active: false, backend: 'none' },
|
execute: () => computer?.status?.() || { active: false, backend: 'none' },
|
||||||
},
|
},
|
||||||
|
|||||||
+16
-6
@@ -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.
|
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.
|
Every reply must be speakable out loud by text to speech. Write only words and numbers a person can say.
|
||||||
Computer use requires an explicit user grant; never click or type while it is inactive, locked, or revoked.
|
Reply in plain text only. Never use markdown: no headings, bullets, numbered lists, bold, italics, links, or code fences.
|
||||||
Destructive actions require confirmation in both the HUD and spoken conversation.
|
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.
|
Prefer structured tools and accessibility references over coordinates.
|
||||||
For questions about the computer, files, processes, or system state, call the
|
For questions about the computer, files, processes, or system state, call the
|
||||||
most relevant registered tool before answering. Never say that computer use or
|
most relevant registered tool before answering. Never say that computer use or
|
||||||
terminal access is unavailable unless a tool result reports that limitation.
|
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
|
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>.
|
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) {
|
export function parseHudSidecar(text) {
|
||||||
const source = String(text || '');
|
const source = String(text || '');
|
||||||
|
|||||||
@@ -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
@@ -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 () => {
|
test('harness bridge recovers streamed text when final envelope is empty after a tool call', async () => {
|
||||||
const session = new (await import('node:events')).EventEmitter();
|
const session = new (await import('node:events')).EventEmitter();
|
||||||
session.prompt = async () => {
|
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 daemon = new JarvisDaemon();
|
||||||
const calls = [];
|
const calls = [];
|
||||||
daemon.harness = { session: { permit(...args) { calls.push(args); } }, cancel() {}, close: async () => {} };
|
daemon.harness = { session: { permit(...args) { calls.push(args); } }, cancel() {}, close: async () => {} };
|
||||||
try {
|
try {
|
||||||
daemon.confirmPermission('job-1', 'call-9', 'allow');
|
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 {
|
} finally {
|
||||||
await daemon.close();
|
await daemon.close();
|
||||||
}
|
}
|
||||||
|
|||||||
+289
-97
@@ -3,41 +3,93 @@ import assert from 'node:assert/strict';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import vm from 'node:vm';
|
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() {
|
function harness() {
|
||||||
const timers = new Map();
|
const timers = new Map();
|
||||||
|
const chrome = [];
|
||||||
class Actor {
|
class Actor {
|
||||||
constructor(props = {}) {
|
constructor(props = {}) {
|
||||||
if ('hexpand' in props) throw new Error('No property hexpand on StWidget');
|
if ('hexpand' in props) throw new Error('No property hexpand on StWidget');
|
||||||
Object.assign(this, props);
|
Object.assign(this, props);
|
||||||
this.children = [];
|
this.children = [];
|
||||||
this.visible = props.visible !== false;
|
this.visible = props.visible !== false;
|
||||||
|
this.text = props.text || props.hint_text || '';
|
||||||
this.clutter_text = { connect() {}, ellipsize: null };
|
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 = []; }
|
destroy_all_children() { this.children = []; }
|
||||||
get_n_children() { return this.children.length; }
|
get_n_children() { return this.children.length; }
|
||||||
contains() { return false; }
|
contains() { return false; }
|
||||||
get_last_child() { return this.children[this.children.length - 1] || null; }
|
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; }
|
hide() { this.visible = false; }
|
||||||
show() { this.visible = true; }
|
show() { this.visible = true; }
|
||||||
destroy() { this.destroyed = true; }
|
destroy() { this.destroyed = true; this.visible = false; }
|
||||||
set_position() {}
|
set_position() {}
|
||||||
set_width() {}
|
set_width() {}
|
||||||
set_height() {}
|
set_height() {}
|
||||||
grab_key_focus() {}
|
grab_key_focus() {}
|
||||||
set_style() {}
|
set_style() {}
|
||||||
add_style_class_name() {}
|
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({
|
const context = vm.createContext({
|
||||||
Extension: class {},
|
Extension: class {},
|
||||||
global: { stage: { get_key_focus() { return null; } } },
|
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 } },
|
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 } },
|
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: {
|
GLib: {
|
||||||
PRIORITY_DEFAULT: 0, PRIORITY_DEFAULT_IDLE: 200, SOURCE_REMOVE: false,
|
PRIORITY_DEFAULT: 0, PRIORITY_DEFAULT_IDLE: 200, SOURCE_REMOVE: false,
|
||||||
idle_add(_priority, callback) { callback(); return 1; },
|
idle_add(_priority, callback) { callback(); return 1; },
|
||||||
@@ -46,75 +98,98 @@ function harness() {
|
|||||||
},
|
},
|
||||||
log() {},
|
log() {},
|
||||||
});
|
});
|
||||||
vm.runInContext(source.replace(/^import .*;\n/gm, '').replace('export default class', 'class') +
|
vm.runInContext(
|
||||||
'\nglobalThis.classes = { ArcOverlay, JarvisExtension, JarvisProxy };', context);
|
`${stripModules(uiSource)}\n${stripModules(extensionSource)}\nglobalThis.classes = { ConversationView, JarvisOsd, ComputerUseChrome, SessionPanel, JarvisExtension, JarvisProxy };`,
|
||||||
return { ...context.classes, timers };
|
context,
|
||||||
|
);
|
||||||
|
return { ...context.classes, timers, chrome, menu };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('overlay constructs and destroys with pending halo animation', () => {
|
test('compact popup constructs without a floating 720px overlay', () => {
|
||||||
const { ArcOverlay, timers } = harness();
|
const { ConversationView } = harness();
|
||||||
const overlay = new ArcOverlay();
|
const popup = new ConversationView({ compact: true });
|
||||||
assert.equal(overlay.header.children[1].x_expand, true);
|
assert.equal(popup.compact, true);
|
||||||
overlay.attach();
|
assert.match(popup.root.style_class, /jarvis-popup/);
|
||||||
overlay.show(true);
|
assert.equal(popup.header.children[1].x_expand, true);
|
||||||
overlay.showHalo();
|
assert.equal(popup.expand.label, 'Open');
|
||||||
overlay.showHalo();
|
assert.equal(popup.expand.accessible_name, 'Open conversation');
|
||||||
assert.equal(timers.size, 1);
|
assert.equal(popup.settings.label, 'Settings');
|
||||||
overlay.destroy();
|
assert.equal(popup.header.children.includes(popup.settings), true);
|
||||||
assert.equal(timers.size, 0);
|
assert.doesNotMatch(popup.root.style_class, /jarvis-arc/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('empty chrome widgets start hidden', () => {
|
test('empty chrome widgets start hidden', () => {
|
||||||
const { ArcOverlay } = harness();
|
const { ConversationView, ComputerUseChrome } = harness();
|
||||||
const overlay = new ArcOverlay();
|
const popup = new ConversationView({ compact: true });
|
||||||
assert.equal(overlay.job.visible, false);
|
const cu = new ComputerUseChrome();
|
||||||
assert.equal(overlay.target.visible, false);
|
assert.equal(popup.confirm.visible, false);
|
||||||
assert.equal(overlay.cursor.visible, false);
|
assert.equal(popup.thinking.visible, false);
|
||||||
assert.equal(overlay.wave.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]', () => {
|
test('Reply finalizes a streaming row instead of duplicating [object Object]', () => {
|
||||||
const { ArcOverlay } = harness();
|
const { ConversationView } = harness();
|
||||||
const overlay = new ArcOverlay();
|
const popup = new ConversationView({ compact: true });
|
||||||
overlay.token('Hello! I am ready.');
|
popup.token('Hello! I am ready.');
|
||||||
overlay.finalizeReply('[object Object]');
|
popup.finalizeReply('[object Object]');
|
||||||
overlay.finalizeReply({ text: 'ignored duplicate' });
|
popup.finalizeReply({ text: 'ignored duplicate' });
|
||||||
assert.equal(overlay.transcript.children.length, 1);
|
assert.equal(popup.transcript.children.length, 1);
|
||||||
assert.match(overlay.transcript.children[0].text, /Hello! I am ready/);
|
assert.match(popup.transcript.children[0].text, /Hello! I am ready/);
|
||||||
assert.doesNotMatch(overlay.transcript.children[0].text, /object Object/);
|
assert.doesNotMatch(popup.transcript.children[0].text, /object Object/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('final reply is shown after tool result rows', () => {
|
test('final reply is shown after tool result rows', () => {
|
||||||
const { ArcOverlay } = harness();
|
const { ConversationView } = harness();
|
||||||
const overlay = new ArcOverlay();
|
const popup = new ConversationView({ compact: true });
|
||||||
overlay.addToolCall('{"name":"runtime_status"}');
|
popup.addToolCall('{"name":"runtime_status"}');
|
||||||
overlay.addToolResult('{"name":"runtime_status"}');
|
popup.addToolResult('{"name":"runtime_status"}');
|
||||||
overlay.finalizeReply('Your computer is ready.');
|
popup.finalizeReply('Your computer is ready.');
|
||||||
assert.equal(overlay.transcript.children.length, 3);
|
assert.equal(popup.transcript.children.length, 3);
|
||||||
assert.match(overlay.transcript.children[2].text, /Your computer is ready/);
|
assert.match(popup.transcript.children[2].text, /Your computer is ready/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tokens after a tool result start a new spoken Jarvis row', () => {
|
test('tokens after a tool result start a new spoken Jarvis row', () => {
|
||||||
const { ArcOverlay } = harness();
|
const { ConversationView } = harness();
|
||||||
const overlay = new ArcOverlay();
|
const popup = new ConversationView({ compact: true });
|
||||||
overlay.addToolCall('{"name":"capability_status"}');
|
popup.addToolCall('{"name":"capability_status"}');
|
||||||
overlay.addToolResult('{"name":"capability_status"}');
|
popup.addToolResult('{"name":"capability_status"}');
|
||||||
overlay.token('Your computer is ready.');
|
popup.token('Your computer is ready.');
|
||||||
overlay.finalizeReply('Your computer is ready.');
|
popup.finalizeReply('Your computer is ready.');
|
||||||
assert.equal(overlay.transcript.children.length, 3);
|
assert.equal(popup.transcript.children.length, 3);
|
||||||
assert.match(overlay.transcript.children[2].text, /Your computer is ready/);
|
assert.match(popup.transcript.children[2].text, /Your computer is ready/);
|
||||||
assert.doesNotMatch(overlay.transcript.children[2].style_class, /jarvis-row-tool/);
|
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', () => {
|
test('addRow and token coerce objects to readable text', () => {
|
||||||
const { ArcOverlay } = harness();
|
const { ConversationView } = harness();
|
||||||
const overlay = new ArcOverlay();
|
const popup = new ConversationView({ compact: true });
|
||||||
overlay.addRow('U', { text: 'Hi there' });
|
popup.addRow('U', { text: 'Hi there' });
|
||||||
overlay.token({ text: 'Hello from Jarvis' });
|
popup.token({ text: 'Hello from Jarvis' });
|
||||||
assert.equal(overlay.transcript.children.length, 2);
|
assert.equal(popup.transcript.children.length, 2);
|
||||||
assert.match(overlay.transcript.children[0].text, /Hi there/);
|
assert.match(popup.transcript.children[0].text, /Hi there/);
|
||||||
assert.match(overlay.transcript.children[1].text, /Hello from Jarvis/);
|
assert.match(popup.transcript.children[1].text, /Hello from Jarvis/);
|
||||||
assert.doesNotMatch(overlay.transcript.children.map((row) => row.text).join('\n'), /object Object/);
|
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]) {
|
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; }) };
|
extension.proxy = { connect: () => new Promise((resolve, reject) => { finish = fails ? reject : resolve; }) };
|
||||||
const pending = extension._connectDaemon();
|
const pending = extension._connectDaemon();
|
||||||
extension.proxy = null;
|
extension.proxy = null;
|
||||||
extension.overlay = null;
|
extension.popup = null;
|
||||||
|
extension.session = null;
|
||||||
finish(fails ? new Error('Disconnected') : undefined);
|
finish(fails ? new Error('Disconnected') : undefined);
|
||||||
await pending;
|
await pending;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('GNOME GI imports expose default namespaces and panel has a real menu', () => {
|
test('GNOME GI imports expose default namespaces and panel has a real menu', () => {
|
||||||
assert.match(source, /import Shell from 'gi:\/\/Shell'/);
|
assert.match(extensionSource, /import Shell from 'gi:\/\/Shell'/);
|
||||||
assert.match(source, /import Meta from 'gi:\/\/Meta'/);
|
assert.match(extensionSource, /import Meta from 'gi:\/\/Meta'/);
|
||||||
assert.match(source, /import Pango from 'gi:\/\/Pango'/);
|
assert.match(extensionSource, /new PanelMenu.Button\(0.0, 'Jarvis QVAC', false\)/);
|
||||||
assert.match(source, /new PanelMenu.Button\(0.0, 'Jarvis QVAC', false\)/);
|
assert.match(extensionSource, /PushToTalk/);
|
||||||
assert.match(source, /PushToTalk/);
|
assert.match(extensionSource, /\['Thinking'/);
|
||||||
assert.match(source, /finalizeReply/);
|
assert.match(extensionSource, /\['ToolCall'/);
|
||||||
assert.match(source, /\['Thinking'/);
|
assert.match(extensionSource, /_openPopup/);
|
||||||
assert.match(source, /\['ToolCall'/);
|
assert.match(uiSource, /Open conversation/);
|
||||||
assert.match(source, /Minimize/);
|
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', () => {
|
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'/);
|
assert.match(dbusSource, /Confirm: \{ inSignature: 'sss'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('minimize stays closed while Jarvis speaks, then restore shows the transcript', () => {
|
test('session panel stays closed while Jarvis speaks unless expanded', () => {
|
||||||
const { ArcOverlay } = harness();
|
const { SessionPanel } = harness();
|
||||||
const overlay = new ArcOverlay();
|
const session = new SessionPanel();
|
||||||
overlay.attach();
|
session.attach();
|
||||||
overlay.show(true);
|
session.view.setState('SPEAKING');
|
||||||
overlay.minimize();
|
session.view.finalizeReply('I am still talking in the background.');
|
||||||
overlay.setState('SPEAKING');
|
assert.equal(session.root.visible, false);
|
||||||
overlay.finalizeReply('I am still talking in the background.');
|
session.show(true);
|
||||||
assert.equal(overlay.root.visible, false);
|
assert.equal(session.root.visible, true);
|
||||||
overlay.show(true);
|
assert.match(session.view.transcript.children[0].text, /still talking/);
|
||||||
assert.equal(overlay.root.visible, true);
|
});
|
||||||
assert.match(overlay.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', () => {
|
test('ConfirmationRequired chips answer Confirm with job and tool ids', () => {
|
||||||
const { ArcOverlay } = harness();
|
const { ConversationView } = harness();
|
||||||
const overlay = new ArcOverlay();
|
const popup = new ConversationView({ compact: true });
|
||||||
const answers = [];
|
const answers = [];
|
||||||
overlay.onConfirm = (...args) => answers.push(args);
|
const shown = [];
|
||||||
overlay.offerConfirm('write_file', JSON.stringify({ jobId: 'job-1', toolCallId: 'call-9', args: { path: '~/notes' } }), 'destructive');
|
popup.onConfirm = (...args) => answers.push(args);
|
||||||
assert.equal(overlay.confirm.visible, true);
|
popup.onConfirmShown = () => shown.push(true);
|
||||||
overlay._answerConfirm('allow');
|
popup.offerConfirm('write_file', JSON.stringify({ jobId: 'job-1', toolCallId: 'call-9', args: { path: '~/notes' } }), 'destructive');
|
||||||
assert.equal(overlay.confirm.visible, false);
|
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']]);
|
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', () => {
|
test('errors and reset failures are one-line notices, not chat rows', () => {
|
||||||
const { ArcOverlay } = harness();
|
const { ConversationView } = harness();
|
||||||
const overlay = new ArcOverlay();
|
const popup = new ConversationView({ compact: true });
|
||||||
overlay.setNotice('ResetContext failed:\nTypeError: Cannot read properties of undefined (reading \'apply\')');
|
popup.setNotice('ResetContext failed:\nTypeError: Cannot read properties of undefined (reading \'apply\')');
|
||||||
assert.equal(overlay.transcript.children.length, 0);
|
assert.equal(popup.transcript.children.length, 0);
|
||||||
assert.equal(overlay.notice.visible, true);
|
assert.equal(popup.notice.visible, true);
|
||||||
assert.doesNotMatch(overlay.notice.text, /\n/);
|
assert.doesNotMatch(popup.notice.text, /\n/);
|
||||||
overlay.clear();
|
popup.clear();
|
||||||
overlay.setNotice('New conversation');
|
popup.setNotice('New conversation');
|
||||||
assert.equal(overlay.notice.text, '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', () => {
|
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, /wakePhrase/);
|
||||||
assert.match(prefs, /ttsEnabled/);
|
assert.match(prefs, /ttsEnabled/);
|
||||||
assert.match(prefs, /modelProfile/);
|
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
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { chmod, cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
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 os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -127,3 +127,12 @@ exit 0
|
|||||||
await rm(work, { recursive: true, force: true });
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createRuntimeTools } from '../skills/runtime-tools.js';
|
import { createRuntimeTools } from '../skills/runtime-tools.js';
|
||||||
import { assertSdkVersion } from '../daemon/qvac-master.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 { createPhase2Tools } from '../skills/phase2-tools.js';
|
||||||
import { createQvacTools } from '../skills/qvac-tools.js';
|
import { createQvacTools } from '../skills/qvac-tools.js';
|
||||||
import { profile } from '../daemon/model-profiles.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');
|
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 () => {
|
test('phase 2 registers safe local tools with permission metadata', async () => {
|
||||||
const tools = createPhase2Tools({ cwd: process.cwd() });
|
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']);
|
assert.deepEqual(tools.map((tool) => tool.name), ['app_list', 'fs_search', 'fs_read', 'fs_write', 'memory_recall', 'memory_remember', 'rag_workspaces', 'capability_status']);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -7,13 +7,19 @@ import { pcmS16le } from '../daemon/voice-adapters.js';
|
|||||||
import { WakeEngine } from '../daemon/wake-engine.js';
|
import { WakeEngine } from '../daemon/wake-engine.js';
|
||||||
import { VadSegmenter } from '../daemon/vad.js';
|
import { VadSegmenter } from '../daemon/vad.js';
|
||||||
import { PipeWireCapture, pcmRms } from '../daemon/audio-pipewire.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', () => {
|
test('Phase 4 transcript filtering and sentence buffering are deterministic', () => {
|
||||||
assert.equal(isMeaningfulTranscript('[BLANK_AUDIO]'), false);
|
assert.equal(isMeaningfulTranscript('[BLANK_AUDIO]'), false);
|
||||||
assert.equal(isMeaningfulTranscript('hi'), false);
|
assert.equal(isMeaningfulTranscript('hi'), false);
|
||||||
assert.equal(isSpeakable('OK.'), true);
|
assert.equal(isSpeakable('OK.'), true);
|
||||||
assert.equal(isSpeakable('[object Object]'), false);
|
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);
|
assert.equal(isMeaningfulTranscript('what time is it'), true);
|
||||||
const out = []; const buffer = new SentenceBuffer({ onSentence: (s) => out.push(s) });
|
const out = []; const buffer = new SentenceBuffer({ onSentence: (s) => out.push(s) });
|
||||||
buffer.push('First sentence. Second'); buffer.push(' sentence!'); buffer.flush();
|
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);
|
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', () => {
|
test('PCM packing uses even s16le sample pairs', () => {
|
||||||
const buf = Buffer.alloc(4);
|
const buf = Buffer.alloc(4);
|
||||||
buf.writeInt16LE(256, 0);
|
buf.writeInt16LE(256, 0);
|
||||||
|
|||||||
+71
-13
@@ -1,5 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Context compaction: heuristic fallback + optional one-shot LLM summary.
|
* 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');
|
const truncate = require('./truncate.js');
|
||||||
@@ -7,6 +10,7 @@ const truncate = require('./truncate.js');
|
|||||||
const CHAR_PER_TOKEN = 3;
|
const CHAR_PER_TOKEN = 3;
|
||||||
const THRESHOLD = 0.68;
|
const THRESHOLD = 0.68;
|
||||||
const MIN_SUMMARY = 80;
|
const MIN_SUMMARY = 80;
|
||||||
|
const MIN_COMPACT_MESSAGES = 4;
|
||||||
const COMPACT_PROMPT =
|
const COMPACT_PROMPT =
|
||||||
'Summarize this coding-agent conversation. Use exactly these sections:\n' +
|
'Summarize this coding-agent conversation. Use exactly these sections:\n' +
|
||||||
'1. Goal\n' +
|
'1. Goal\n' +
|
||||||
@@ -15,6 +19,13 @@ const COMPACT_PROMPT =
|
|||||||
'4. Open work\n' +
|
'4. Open work\n' +
|
||||||
'5. Next action\n' +
|
'5. Next action\n' +
|
||||||
'Be specific (paths, names, errors). Do not say the conversation was compacted.';
|
'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) {
|
function contentChars(content) {
|
||||||
if (content == null) return 0;
|
if (content == null) return 0;
|
||||||
@@ -42,23 +53,38 @@ function messageChars(m) {
|
|||||||
return n + 8;
|
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;
|
let n = 0;
|
||||||
for (const m of messages || []) n += messageChars(m);
|
for (const m of messages || []) n += messageChars(m);
|
||||||
n += JSON.stringify(tools || []).length;
|
|
||||||
return Math.ceil(n / CHAR_PER_TOKEN);
|
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) {
|
function historyBudget(ctxSize, tools, attempt) {
|
||||||
const cap = ctxSize > 0 ? Number(ctxSize) : 8192;
|
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)));
|
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);
|
return Math.max(240, Math.floor(cap * 0.72) - toolTok - reserve);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldCompact(messages, tools, ctxSize) {
|
function shouldCompact(messages, tools, ctxSize) {
|
||||||
const cap = ctxSize > 0 ? ctxSize : 8192;
|
if (nonSystemCount(messages) < MIN_COMPACT_MESSAGES) return false;
|
||||||
return estimateTokens(messages, tools) > Math.floor(cap * THRESHOLD);
|
return conversationTokens(messages) > historyBudget(ctxSize, tools, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOverflowError(err) {
|
function isOverflowError(err) {
|
||||||
@@ -153,23 +179,30 @@ function lastRealUserIndex(list) {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function overHistoryBudget(keep, budget) {
|
||||||
|
return conversationTokens(keep) > budget;
|
||||||
|
}
|
||||||
|
|
||||||
function heuristicCompact(messages, opts) {
|
function heuristicCompact(messages, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
const budget = opts.budgetTokens || 6000;
|
const budget = opts.budgetTokens || 6000;
|
||||||
const aggressive = !!opts.aggressive;
|
const aggressive = !!opts.aggressive;
|
||||||
|
if (!aggressive && nonSystemCount(messages) < MIN_COMPACT_MESSAGES) {
|
||||||
|
return (messages || []).slice();
|
||||||
|
}
|
||||||
let keep = 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');
|
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) idx = keep.findIndex((m, i) => i > 1 && m.role === 'assistant');
|
||||||
if (idx < 0) break;
|
if (idx < 0) break;
|
||||||
keep.splice(idx, 1);
|
keep.splice(idx, 1);
|
||||||
}
|
}
|
||||||
const maxMsg = aggressive ? 1200 : 3200;
|
const maxMsg = aggressive ? 1200 : 3200;
|
||||||
if (estimateTokens(keep, opts.tools) > budget) {
|
if (overHistoryBudget(keep, budget)) {
|
||||||
const lastUser = lastRealUserIndex(keep);
|
const lastUser = lastRealUserIndex(keep);
|
||||||
keep = keep.map((m, i) => (i === 0 || i === lastUser ? m : truncateMsg(m, maxMsg)));
|
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 head = keep[0];
|
||||||
const lastUserIdx = lastRealUserIndex(keep);
|
const lastUserIdx = lastRealUserIndex(keep);
|
||||||
const lastUser = lastUserIdx >= 0 ? keep[lastUserIdx] : null;
|
const lastUser = lastUserIdx >= 0 ? keep[lastUserIdx] : null;
|
||||||
@@ -183,7 +216,7 @@ function heuristicCompact(messages, opts) {
|
|||||||
keep = keep.concat(tail);
|
keep = keep.concat(tail);
|
||||||
keep = keep.map((m, i) => (i === 0 ? m : truncateMsg(m, aggressive ? 700 : 1800)));
|
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));
|
const dropAt = keep.findIndex((m, i) => i > 1 && !isRealUser(m));
|
||||||
if (dropAt < 0) break;
|
if (dropAt < 0) break;
|
||||||
keep.splice(dropAt, 1);
|
keep.splice(dropAt, 1);
|
||||||
@@ -205,7 +238,12 @@ function transcript(messages) {
|
|||||||
.join('\n\n');
|
.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 list = messages || [];
|
||||||
const last = list[list.length - 1];
|
const last = list[list.length - 1];
|
||||||
if (!last) return null;
|
if (!last) return null;
|
||||||
@@ -219,7 +257,14 @@ function autoContinue(messages) {
|
|||||||
return null;
|
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 (
|
return (
|
||||||
'<system-reminder>\n' +
|
'<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' +
|
'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) {
|
async function compactWithLlm(messages, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
const fallback = () => heuristicCompact(messages, opts);
|
const fallback = () => heuristicCompact(messages, opts);
|
||||||
|
if (!opts.aggressive && nonSystemCount(messages) < MIN_COMPACT_MESSAGES) {
|
||||||
|
return (messages || []).slice();
|
||||||
|
}
|
||||||
const complete = opts.complete;
|
const complete = opts.complete;
|
||||||
if (typeof complete !== 'function') return fallback();
|
if (typeof complete !== 'function') return fallback();
|
||||||
|
const voice = isVoice(opts);
|
||||||
const cap =
|
const cap =
|
||||||
opts.maxTranscriptChars ||
|
opts.maxTranscriptChars ||
|
||||||
Math.max(1500, Math.min(24000, Math.floor((opts.budgetTokens || 4000) * CHAR_PER_TOKEN * 0.45)));
|
Math.max(1500, Math.min(24000, Math.floor((opts.budgetTokens || 4000) * CHAR_PER_TOKEN * 0.45)));
|
||||||
const body = truncate.truncateWithMarker(transcript(messages), cap);
|
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 {
|
try {
|
||||||
const result = await complete({
|
const result = await complete({
|
||||||
history: [
|
history: [
|
||||||
{ role: 'system', content: 'Reply with the five summary sections only. No tools.' },
|
{ role: 'system', content: sys },
|
||||||
{ role: 'user', content: COMPACT_PROMPT + '\n\n---\n\n' + body },
|
{ role: 'user', content: prompt + '\n\n---\n\n' + body },
|
||||||
],
|
],
|
||||||
tools: [],
|
tools: [],
|
||||||
});
|
});
|
||||||
@@ -256,10 +309,15 @@ module.exports = {
|
|||||||
CHAR_PER_TOKEN,
|
CHAR_PER_TOKEN,
|
||||||
THRESHOLD,
|
THRESHOLD,
|
||||||
MIN_SUMMARY,
|
MIN_SUMMARY,
|
||||||
|
MIN_COMPACT_MESSAGES,
|
||||||
COMPACT_PROMPT,
|
COMPACT_PROMPT,
|
||||||
|
VOICE_COMPACT_PROMPT,
|
||||||
|
conversationTokens,
|
||||||
|
toolTokens,
|
||||||
estimateTokens,
|
estimateTokens,
|
||||||
historyBudget,
|
historyBudget,
|
||||||
shouldCompact,
|
shouldCompact,
|
||||||
|
nonSystemCount,
|
||||||
isOverflowError,
|
isOverflowError,
|
||||||
usage,
|
usage,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
|||||||
Vendored
+75
-22
@@ -15,6 +15,7 @@ const toolSet = require('./tool-set.js');
|
|||||||
const planMode = require('./plan-mode.js');
|
const planMode = require('./plan-mode.js');
|
||||||
const todos = require('./todos.js');
|
const todos = require('./todos.js');
|
||||||
const stationarity = require('./stationarity.js');
|
const stationarity = require('./stationarity.js');
|
||||||
|
const toolBudget = require('./tool-budget.js');
|
||||||
const goalMod = require('./goal.js');
|
const goalMod = require('./goal.js');
|
||||||
const truncate = require('./truncate.js');
|
const truncate = require('./truncate.js');
|
||||||
const sr = require('./search-replace.js');
|
const sr = require('./search-replace.js');
|
||||||
@@ -160,7 +161,18 @@ function resolvePermission(jobId, toolCallId, decision) {
|
|||||||
if (fn) {
|
if (fn) {
|
||||||
pendingPerms.delete(key);
|
pendingPerms.delete(key);
|
||||||
fn(decision);
|
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) {
|
function waitCustomResult(jobId, toolCallId) {
|
||||||
@@ -260,11 +272,15 @@ function applyPlanWrite(session, name, args) {
|
|||||||
return { ok: true, path: file, bytes: text.length };
|
return { ok: true, path: file, bytes: text.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sidecarMessages(session, tracker) {
|
function sidecarMessages(session, tracker, budget) {
|
||||||
const extra = [];
|
const extra = [];
|
||||||
|
if (budget && budget.answerOnly) {
|
||||||
|
extra.push({ role: 'user', content: toolBudget.answerNowMessage() });
|
||||||
|
}
|
||||||
if (tracker && tracker.pendingCompactReminder) {
|
if (tracker && tracker.pendingCompactReminder) {
|
||||||
extra.push({ role: 'user', content: compaction.compactReminder() });
|
const voice = !!(budget && budget.voice);
|
||||||
const cont = compaction.autoContinue(session.history);
|
extra.push({ role: 'user', content: compaction.compactReminder({ voice }) });
|
||||||
|
const cont = compaction.autoContinue(session.history, { voice });
|
||||||
if (cont) extra.push(cont);
|
if (cont) extra.push(cont);
|
||||||
tracker.pendingCompactReminder = false;
|
tracker.pendingCompactReminder = false;
|
||||||
}
|
}
|
||||||
@@ -368,6 +384,7 @@ async function runTurn(ctx) {
|
|||||||
|
|
||||||
await ensureModel(session.model);
|
await ensureModel(session.model);
|
||||||
|
|
||||||
|
const voice = (payload && payload.voice === true) || origin === 'jarvis-qvac';
|
||||||
let extraSys = payload && payload.system;
|
let extraSys = payload && payload.system;
|
||||||
if (session.goal && goalMod.isActive(session.goal)) {
|
if (session.goal && goalMod.isActive(session.goal)) {
|
||||||
extraSys = [extraSys, goalMod.plannerAddendum(session.goal)].filter(Boolean).join('\n\n');
|
extraSys = [extraSys, goalMod.plannerAddendum(session.goal)].filter(Boolean).join('\n\n');
|
||||||
@@ -376,7 +393,9 @@ async function runTurn(ctx) {
|
|||||||
cwd: hostWorkspace ? cwd : session.workspace || cwd,
|
cwd: hostWorkspace ? cwd : session.workspace || cwd,
|
||||||
hostWorkspace,
|
hostWorkspace,
|
||||||
extra: extraSys,
|
extra: extraSys,
|
||||||
fsRead: hostWorkspace
|
personality: voice ? 'voice' : undefined,
|
||||||
|
fsRead:
|
||||||
|
hostWorkspace && !voice
|
||||||
? (c, r) => {
|
? (c, r) => {
|
||||||
try {
|
try {
|
||||||
return fsRead(c, r);
|
return fsRead(c, r);
|
||||||
@@ -388,8 +407,10 @@ async function runTurn(ctx) {
|
|||||||
});
|
});
|
||||||
const sidecars = [];
|
const sidecars = [];
|
||||||
if (hostWorkspace) {
|
if (hostWorkspace) {
|
||||||
|
if (!voice) {
|
||||||
const gitText = await gitSidecar.gitStatusSb(cwd, { hostWorkspace: true, run: tools.runShell });
|
const gitText = await gitSidecar.gitStatusSb(cwd, { hostWorkspace: true, run: tools.runShell });
|
||||||
if (gitText) sidecars.push(gitText);
|
if (gitText) sidecars.push(gitText);
|
||||||
|
}
|
||||||
const memText = memory.injectBlock(origin, userText || '');
|
const memText = memory.injectBlock(origin, userText || '');
|
||||||
if (memText) sidecars.push(memText);
|
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 cancelled = () => live.get(session.id) && live.get(session.id).cancelled;
|
||||||
const stuck = stationarity.create();
|
const stuck = stationarity.create();
|
||||||
|
const budget = toolBudget.fromPayload(payload, origin);
|
||||||
let lastText = '';
|
let lastText = '';
|
||||||
let goalNudges = 0;
|
let goalNudges = 0;
|
||||||
|
|
||||||
@@ -557,29 +579,39 @@ async function runTurn(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 });
|
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);
|
ctx.planMode = planMode.isActive(tracker);
|
||||||
const ctxSize = loadedCtxSize();
|
const ctxSize = loadedCtxSize();
|
||||||
const beforeLen = session.history.length;
|
const beforeLen = session.history.length;
|
||||||
const beforeUsage = compaction.usage(session.history, toolDefs, ctxSize);
|
const beforeUsage = compaction.usage(session.history, toolDefs, ctxSize);
|
||||||
emitLive(emit, session.id, jobId, Object.assign({ type: 'context' }, beforeUsage));
|
emitLive(emit, session.id, jobId, Object.assign({ type: 'context' }, beforeUsage));
|
||||||
if (compaction.shouldCompact(session.history, toolDefs, ctxSize)) {
|
if (compaction.shouldCompact(session.history, toolDefs, ctxSize)) {
|
||||||
|
const useLlm = !budget.voice || compaction.nonSystemCount(session.history) >= 8;
|
||||||
emitUpdate(emit, session.id, jobId, {
|
emitUpdate(emit, session.id, jobId, {
|
||||||
type: 'compaction',
|
type: 'compaction',
|
||||||
status: 'start',
|
status: 'start',
|
||||||
method: 'llm',
|
method: useLlm ? 'llm' : 'heuristic',
|
||||||
used: beforeUsage.used,
|
used: beforeUsage.used,
|
||||||
limit: beforeUsage.limit,
|
limit: beforeUsage.limit,
|
||||||
pct: beforeUsage.pct,
|
pct: beforeUsage.pct,
|
||||||
threshold: beforeUsage.threshold,
|
threshold: beforeUsage.threshold,
|
||||||
});
|
});
|
||||||
session.history = await compaction.compactWithLlm(session.history, {
|
const compactOpts = {
|
||||||
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0),
|
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0),
|
||||||
tools: toolDefs,
|
tools: toolDefs,
|
||||||
|
voice: !!budget.voice,
|
||||||
|
};
|
||||||
|
session.history = useLlm
|
||||||
|
? await compaction.compactWithLlm(
|
||||||
|
session.history,
|
||||||
|
Object.assign({}, compactOpts, {
|
||||||
complete: (opts) => engine.complete(Object.assign({}, opts, { desktopVision: false })),
|
complete: (opts) => engine.complete(Object.assign({}, opts, { desktopVision: false })),
|
||||||
});
|
})
|
||||||
|
)
|
||||||
|
: compaction.compact(session.history, compactOpts);
|
||||||
sessions.replaceHistory(session.id, session.history);
|
sessions.replaceHistory(session.id, session.history);
|
||||||
tracker.pendingCompactReminder = true;
|
tracker.pendingCompactReminder = true;
|
||||||
if (hostWorkspace) {
|
if (hostWorkspace) {
|
||||||
@@ -593,7 +625,7 @@ async function runTurn(ctx) {
|
|||||||
emitUpdate(emit, session.id, jobId, {
|
emitUpdate(emit, session.id, jobId, {
|
||||||
type: 'compaction',
|
type: 'compaction',
|
||||||
status: 'done',
|
status: 'done',
|
||||||
method: 'llm',
|
method: useLlm ? 'llm' : 'heuristic',
|
||||||
used: afterUsage.used,
|
used: afterUsage.used,
|
||||||
limit: afterUsage.limit,
|
limit: afterUsage.limit,
|
||||||
pct: afterUsage.pct,
|
pct: afterUsage.pct,
|
||||||
@@ -605,6 +637,7 @@ async function runTurn(ctx) {
|
|||||||
session.history = compaction.compact(session.history, {
|
session.history = compaction.compact(session.history, {
|
||||||
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0),
|
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, 0),
|
||||||
tools: toolDefs,
|
tools: toolDefs,
|
||||||
|
voice: !!budget.voice,
|
||||||
});
|
});
|
||||||
if (session.history.length !== beforeLen) {
|
if (session.history.length !== beforeLen) {
|
||||||
sessions.replaceHistory(session.id, session.history);
|
sessions.replaceHistory(session.id, session.history);
|
||||||
@@ -624,7 +657,7 @@ async function runTurn(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emitUpdate(emit, session.id, jobId, { type: 'turn', turn });
|
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));
|
emitLive(emit, session.id, jobId, Object.assign({ type: 'context' }, streamBase));
|
||||||
let streamChars = 0;
|
let streamChars = 0;
|
||||||
function liveUsed() {
|
function liveUsed() {
|
||||||
@@ -636,7 +669,7 @@ async function runTurn(ctx) {
|
|||||||
}
|
}
|
||||||
let result;
|
let result;
|
||||||
for (let overflowTry = 0; overflowTry < 4; overflowTry++) {
|
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);
|
streamBase = compaction.usage(history, toolDefs, ctxSize);
|
||||||
streamChars = 0;
|
streamChars = 0;
|
||||||
try {
|
try {
|
||||||
@@ -681,6 +714,7 @@ async function runTurn(ctx) {
|
|||||||
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, overflowTry + 1),
|
budgetTokens: compaction.historyBudget(ctxSize, toolDefs, overflowTry + 1),
|
||||||
tools: toolDefs,
|
tools: toolDefs,
|
||||||
aggressive: true,
|
aggressive: true,
|
||||||
|
voice: !!budget.voice,
|
||||||
});
|
});
|
||||||
sessions.replaceHistory(session.id, session.history);
|
sessions.replaceHistory(session.id, session.history);
|
||||||
tracker.pendingCompactReminder = true;
|
tracker.pendingCompactReminder = true;
|
||||||
@@ -694,15 +728,17 @@ async function runTurn(ctx) {
|
|||||||
Object.assign({ type: 'context' }, usageFromStats(result && result.stats, liveUsed(), ctxSize))
|
Object.assign({ type: 'context' }, usageFromStats(result && result.stats, liveUsed(), ctxSize))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.text) {
|
let calls = (result && result.toolCalls) || [];
|
||||||
lastText = result.text;
|
if (budget.answerOnly) calls = [];
|
||||||
pushHistory(session, { role: 'assistant', content: result.text });
|
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) {
|
if (!calls.length) {
|
||||||
const goalActive = goalMod.isActive(session.goal);
|
const goalActive = goalMod.isActive(session.goal);
|
||||||
if (goalActive && goalNudges < MAX_GOAL_NUDGES) {
|
if (goalActive && goalNudges < MAX_GOAL_NUDGES && !budget.answerOnly) {
|
||||||
goalNudges += 1;
|
goalNudges += 1;
|
||||||
pushHistory(session, { role: 'user', content: goalMod.continuation(session.goal) });
|
pushHistory(session, { role: 'user', content: goalMod.continuation(session.goal) });
|
||||||
continue;
|
continue;
|
||||||
@@ -710,7 +746,7 @@ async function runTurn(ctx) {
|
|||||||
return endTurn(emit, session, jobId, tracker, {
|
return endTurn(emit, session, jobId, tracker, {
|
||||||
type: 'end',
|
type: 'end',
|
||||||
reason: 'stop',
|
reason: 'stop',
|
||||||
text: result.text || lastText || '',
|
text: (result && result.text) || lastText || toolBudget.lastToolText(session.history) || '',
|
||||||
turns: turn + 1,
|
turns: turn + 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -738,6 +774,13 @@ async function runTurn(ctx) {
|
|||||||
continue;
|
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)) {
|
if (sandbox.needsPermission(name, mode) && !planMode.isPlanFilePath(args.path, tracker.planPath)) {
|
||||||
let remembered = null;
|
let remembered = null;
|
||||||
try {
|
try {
|
||||||
@@ -760,7 +803,7 @@ async function runTurn(ctx) {
|
|||||||
let decision = await waitPermission(jobId, { toolCallId });
|
let decision = await waitPermission(jobId, { toolCallId });
|
||||||
if (decision === 'always') {
|
if (decision === 'always') {
|
||||||
try {
|
try {
|
||||||
permStore.remember(name, args, 'allow');
|
permStore.rememberAlways(name);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
decision = 'allow';
|
decision = 'allow';
|
||||||
}
|
}
|
||||||
@@ -774,6 +817,7 @@ async function runTurn(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
prepared.push({ name, args, toolCallId });
|
prepared.push({ name, args, toolCallId });
|
||||||
|
if (name === 'run_terminal_cmd') toolBudget.markShell(budget);
|
||||||
}
|
}
|
||||||
|
|
||||||
let stopEarly = null;
|
let stopEarly = null;
|
||||||
@@ -795,19 +839,28 @@ async function runTurn(ctx) {
|
|||||||
}
|
}
|
||||||
if (stopEarly) break;
|
if (stopEarly) break;
|
||||||
}
|
}
|
||||||
|
if (prepared.length) toolBudget.markToolRound(budget);
|
||||||
if (stopEarly) {
|
if (stopEarly) {
|
||||||
return endTurn(emit, session, jobId, tracker, stopEarly);
|
return endTurn(emit, session, jobId, tracker, stopEarly);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stuckNow) {
|
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)) {
|
if (stationarity.shouldNudge(stuck)) {
|
||||||
stationarity.markNudged(stuck);
|
stationarity.markNudged(stuck);
|
||||||
pushHistory(session, { role: 'user', content: stationarity.nudgeText() });
|
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) {
|
} catch (err) {
|
||||||
if (err && err.message === 'cancelled') {
|
if (err && err.message === 'cancelled') {
|
||||||
return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', text: lastText });
|
return endTurn(emit, session, jobId, tracker, { reason: 'cancelled', text: lastText });
|
||||||
|
|||||||
+6
-1
@@ -34,4 +34,9 @@ function remember(tool, args, decision) {
|
|||||||
return save(rules.addRule(load(), 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
@@ -29,7 +29,13 @@ function loadWorkspaceRules(fsRead, cwd) {
|
|||||||
return chunks.join('\n\n');
|
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];
|
const parts = [hostWorkspace === false ? PAGE_SYSTEM : DEFAULT_SYSTEM];
|
||||||
if (cwd) parts.push(hostWorkspace === false ? 'Workspace: ' + cwd : 'Current workspace: ' + cwd);
|
if (cwd) parts.push(hostWorkspace === false ? 'Workspace: ' + cwd : 'Current workspace: ' + cwd);
|
||||||
const rules = hostWorkspace === false ? '' : fsRead ? loadWorkspaceRules(fsRead, cwd) : '';
|
const rules = hostWorkspace === false ? '' : fsRead ? loadWorkspaceRules(fsRead, cwd) : '';
|
||||||
|
|||||||
+82
@@ -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,
|
||||||
|
};
|
||||||
Vendored
+52
-19
@@ -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) {
|
async function runShell(cwd, command, timeoutMs) {
|
||||||
let spawn;
|
let spawn;
|
||||||
try {
|
try {
|
||||||
@@ -160,22 +184,28 @@ async function runShell(cwd, command, timeoutMs) {
|
|||||||
const args = isWin ? ['/c', command] : ['-c', command];
|
const args = isWin ? ['/c', command] : ['-c', command];
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
const proc = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
let stdout = '';
|
const readOut = collectStream(proc.stdout, 200000);
|
||||||
let stderr = '';
|
const readErr = collectStream(proc.stderr, 80000);
|
||||||
if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 200000) stdout = stdout.slice(-200000); });
|
let settled = false;
|
||||||
if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 80000) stderr = stderr.slice(-80000); });
|
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
try { proc.kill(); } catch (_) {}
|
try { proc.kill(); } catch (_) {}
|
||||||
reject(new Error('command timed out'));
|
finish(new Error('command timed out'));
|
||||||
}, timeoutMs || 30000);
|
}, timeoutMs || 30000);
|
||||||
proc.on('exit', (code) => {
|
const finish = (err, code) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
resolve({ exitCode: code, stdout, stderr });
|
if (err) reject(err);
|
||||||
});
|
else resolve({ exitCode: code, stdout: readOut(), stderr: readErr() });
|
||||||
proc.on('error', (err) => {
|
};
|
||||||
clearTimeout(t);
|
// Bare's subprocess emits `exit` before it resumes stdio pipes. Wait for
|
||||||
reject(err);
|
// `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: '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: '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_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_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_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'] } },
|
{ 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) {
|
async function webFetch(url) {
|
||||||
const net = require('../lib/net.js');
|
const net = require('../lib/net.js');
|
||||||
net.assertPublicHttpUrl(url);
|
net.assertHttpUrl(url);
|
||||||
const res = await fetch(url, { headers: { 'user-agent': 'agent-harness/0.1' } });
|
const res = await fetch(url, { headers: { 'user-agent': 'agent-harness/0.1' } });
|
||||||
let text = await res.text();
|
let text = await res.text();
|
||||||
text = truncate.truncateWithMarker(text, 80000);
|
text = truncate.truncateWithMarker(text, 80000);
|
||||||
@@ -306,10 +336,13 @@ async function execute(ctx, name, args) {
|
|||||||
}
|
}
|
||||||
case 'run_terminal_cmd': {
|
case 'run_terminal_cmd': {
|
||||||
if (!sandbox.isAllowed(origin, cwd)) throw new Error('cwd not allowlisted');
|
if (!sandbox.isAllowed(origin, cwd)) throw new Error('cwd not allowlisted');
|
||||||
if (!sandbox.shellSafe(args.command)) {
|
const command = String(args.command || '').trim();
|
||||||
throw new Error('command not allowlisted (or contains shell metacharacters)');
|
if (!command) throw new Error('command required');
|
||||||
}
|
// HUD/CLI permission is the gate. The coding-agent allowlist would reject
|
||||||
return runShell(cwd, args.command, args.timeout_ms || args.timeoutMs);
|
// 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': {
|
case 'todo_write': {
|
||||||
const mode = args.merge === false || args.replace === true ? 'replace' : 'merge';
|
const mode = args.merge === false || args.replace === true ? 'replace' : 'merge';
|
||||||
@@ -392,4 +425,4 @@ function ensureParent(abs) {
|
|||||||
} catch (_) {}
|
} 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 };
|
||||||
|
|||||||
Vendored
+4
@@ -40,6 +40,10 @@ function wrapSession(summary, opts) {
|
|||||||
permissionMode: opts.permissionMode || 'ask',
|
permissionMode: opts.permissionMode || 'ask',
|
||||||
webFetch: opts.webFetch === true,
|
webFetch: opts.webFetch === true,
|
||||||
system: opts.system,
|
system: opts.system,
|
||||||
|
maxTurns: opts.maxTurns,
|
||||||
|
maxShellCalls: opts.maxShellCalls,
|
||||||
|
maxToolRounds: opts.maxToolRounds,
|
||||||
|
voice: opts.voice,
|
||||||
},
|
},
|
||||||
payload || {}
|
payload || {}
|
||||||
);
|
);
|
||||||
|
|||||||
Vendored
+7
-2
@@ -32,7 +32,7 @@ function isBlockedHostname(hostname) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertPublicHttpUrl(raw) {
|
function assertHttpUrl(raw) {
|
||||||
let url;
|
let url;
|
||||||
try {
|
try {
|
||||||
url = new URL(String(raw));
|
url = new URL(String(raw));
|
||||||
@@ -42,10 +42,15 @@ function assertPublicHttpUrl(raw) {
|
|||||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||||
throw new Error('only http(s) URLs are allowed');
|
throw new Error('only http(s) URLs are allowed');
|
||||||
}
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPublicHttpUrl(raw) {
|
||||||
|
const url = assertHttpUrl(raw);
|
||||||
if (isBlockedHostname(url.hostname)) {
|
if (isBlockedHostname(url.hostname)) {
|
||||||
throw new Error('private, loopback, and metadata hosts are blocked');
|
throw new Error('private, loopback, and metadata hosts are blocked');
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { isBlockedHostname, assertPublicHttpUrl };
|
module.exports = { isBlockedHostname, assertHttpUrl, assertPublicHttpUrl };
|
||||||
|
|||||||
Vendored
+74
-1
@@ -17,6 +17,7 @@ const stationarity = require('../agent/stationarity.js');
|
|||||||
const truncate = require('../agent/truncate.js');
|
const truncate = require('../agent/truncate.js');
|
||||||
const permRules = require('../agent/perm-rules.js');
|
const permRules = require('../agent/perm-rules.js');
|
||||||
const policy = require('../agent/policy.js');
|
const policy = require('../agent/policy.js');
|
||||||
|
const toolBudget = require('../agent/tool-budget.js');
|
||||||
const paths = require('../lib/paths.js');
|
const paths = require('../lib/paths.js');
|
||||||
const device = require('../lib/device.js');
|
const device = require('../lib/device.js');
|
||||||
|
|
||||||
@@ -35,6 +36,14 @@ function testCatalog() {
|
|||||||
assert.ok(listed && listed.label.indexOf('~3.5 GB') >= 0);
|
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() {
|
function testCompaction() {
|
||||||
const sys = { role: 'system', content: 'sys' };
|
const sys = { role: 'system', content: 'sys' };
|
||||||
const user = { role: 'user', content: 'hello' };
|
const user = { role: 'user', content: 'hello' };
|
||||||
@@ -48,6 +57,39 @@ function testCompaction() {
|
|||||||
assert.ok(out.length < hist.length);
|
assert.ok(out.length < hist.length);
|
||||||
assert.strictEqual(out[0].role, 'system');
|
assert.strictEqual(out[0].role, 'system');
|
||||||
assert.ok(compaction.isOverflowError(new Error('prompt too long for context window')));
|
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() {
|
function testSearchReplace() {
|
||||||
@@ -66,7 +108,17 @@ function testToolSet() {
|
|||||||
];
|
];
|
||||||
const page = toolSet.filterBuiltinSchemas(all, { hostWorkspace: false, builtinTools: ['todo_write'] });
|
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 === '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() {
|
function testPrompts() {
|
||||||
@@ -74,6 +126,18 @@ function testPrompts() {
|
|||||||
assert.ok(prompts.DEFAULT_SYSTEM.indexOf('BridgeSwarm') < 0);
|
assert.ok(prompts.DEFAULT_SYSTEM.indexOf('BridgeSwarm') < 0);
|
||||||
const page = prompts.assemble({ cwd: 'container', hostWorkspace: false });
|
const page = prompts.assemble({ cwd: 'container', hostWorkspace: false });
|
||||||
assert.ok(page.indexOf('host filesystem') >= 0);
|
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() {
|
function testNet() {
|
||||||
@@ -81,6 +145,9 @@ function testNet() {
|
|||||||
assert.throws(() => net.assertPublicHttpUrl('http://192.168.1.1/x'));
|
assert.throws(() => net.assertPublicHttpUrl('http://192.168.1.1/x'));
|
||||||
const u = net.assertPublicHttpUrl('https://example.com/a');
|
const u = net.assertPublicHttpUrl('https://example.com/a');
|
||||||
assert.strictEqual(u.hostname, 'example.com');
|
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() {
|
function testCustomTools() {
|
||||||
@@ -124,6 +191,12 @@ function testTruncateAndPerm() {
|
|||||||
assert.strictEqual(pat, 'git status');
|
assert.strictEqual(pat, 'git status');
|
||||||
assert.ok(policy.shellSafe('git status'));
|
assert.ok(policy.shellSafe('git status'));
|
||||||
assert.ok(!policy.shellSafe('rm -rf /'));
|
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() {
|
function testPaths() {
|
||||||
|
|||||||
Reference in New Issue
Block a user