From 53175760872c2f6c07a1f18ff9398262cede581f Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Sat, 12 Sep 2026 15:30:11 -0400 Subject: [PATCH] Allow to change the agents name + Workspace files --- .../jarvis@qvac.local/extension.js | 20 ++++- .../jarvis@qvac.local/settings-catalog.json | 9 +++ .../jarvis@qvac.local/settings-values.js | 6 +- .../jarvis@qvac.local/settings-window.js | 2 +- apps/gnome-extension/jarvis@qvac.local/ui.js | 9 +++ daemon/agent-workspace.js | 72 +++++++++++++++++ daemon/harness-bridge.js | 15 ++-- daemon/index.js | 11 ++- docs/development.md | 5 +- docs/harness-integration.md | 7 +- docs/settings.md | 4 + skills/voice-prompt.js | 11 ++- test/agent-workspace.test.js | 34 ++++++++ test/compaction.test.js | 15 +++- test/daemon.test.js | 6 ++ test/gnome-extension.test.js | 5 +- test/runtime-tools.test.js | 4 + test/settings.test.js | 6 ++ .../agent-harness/agent-workspace/AGENTS.md | 77 +++++-------------- .../agent-workspace/BOOTSTRAP.md | 12 +-- .../agent-harness/agent-workspace/IDENTITY.md | 12 +-- .../agent-harness/agent-workspace/MEMORY.md | 6 +- vendor/agent-harness/agent-workspace/SOUL.md | 33 ++++---- vendor/agent-harness/agent-workspace/TOOLS.md | 48 +++--------- vendor/agent-harness/agent-workspace/USER.md | 2 +- .../skills/skill-creator/SKILL.md | 21 ++--- vendor/agent-harness/agent/loop.js | 5 +- vendor/agent-harness/agent/policy.js | 20 +++++ vendor/agent-harness/agent/prompts.js | 31 +++++++- vendor/agent-harness/test/test.js | 4 +- 30 files changed, 338 insertions(+), 174 deletions(-) create mode 100644 daemon/agent-workspace.js create mode 100644 test/agent-workspace.test.js diff --git a/apps/gnome-extension/jarvis@qvac.local/extension.js b/apps/gnome-extension/jarvis@qvac.local/extension.js index 389e5c1..1feaee9 100644 --- a/apps/gnome-extension/jarvis@qvac.local/extension.js +++ b/apps/gnome-extension/jarvis@qvac.local/extension.js @@ -65,6 +65,7 @@ export default class JarvisExtension extends Extension { this._bindSurface(this.session.view); this._applyAccessibility(); this._removeShellService = installShellService(); + this._assistantName = 'Jarvis'; this._indicator = new PanelMenu.Button(0.0, 'Jarvis QVAC', false); this._indicator.accessible_name = 'Jarvis voice assistant'; this._panelBox = new St.BoxLayout({ style_class: 'jarvis-panel-box', y_align: Clutter.ActorAlign.CENTER }); this._mark = new St.Icon({ icon_name: 'audio-input-microphone-symbolic', icon_size: 16, style_class: 'jarvis-panel-mark', y_align: Clutter.ActorAlign.CENTER }); @@ -257,11 +258,13 @@ export default class JarvisExtension extends Extension { const tts = Boolean(voice.tts || voice.speech); this._eachView((view) => view.setVoiceStatus({ tts, input, wake: voice.wake })); } + const name = status.settings?.assistantName; + if (name) this._applyAssistantName(name); } catch { this._eachView((view) => view.setConnectionStatus('voice-unavailable')); } } _call(name, signature, value) { if (!this.proxy?.owned?.()) { - if (name !== 'PushToTalk') this._eachView((view) => view.setNotice('Jarvis is starting…')); + if (name !== 'PushToTalk') this._eachView((view) => view.setNotice(`${this._assistantName || 'Jarvis'} is starting…`)); this._connectDaemon(); return; } @@ -272,11 +275,20 @@ export default class JarvisExtension extends Extension { this._eachView((view) => view.setNotice(fallback)); }); } + _applyAssistantName(name) { + const text = safeText(name).slice(0, 32) || 'Jarvis'; + this._assistantName = text; + this._eachView((view) => view.setAssistantName?.(text)); + this._indicator.accessible_name = `${text} voice assistant`; + this._glyph.accessible_name = `${text} idle`; + if (this._glyph.visible !== false) this._glyph.text = text; + } _setState(state) { const value = safeText(state); - this._glyph.text = 'Jarvis'; - this._glyph.accessible_name = `Jarvis ${value.toLowerCase()}`; - this._indicator.accessible_name = `Jarvis ${value.toLowerCase()}`; + const name = this._assistantName || 'Jarvis'; + this._glyph.text = name; + this._glyph.accessible_name = `${name} ${value.toLowerCase()}`; + this._indicator.accessible_name = `${name} ${value.toLowerCase()}`; if (this._panelBox) { for (const name of ['armed', 'listening', 'speaking', 'thinking', 'sleeping']) { this._panelBox.remove_style_class_name(`jarvis-state-${name}`); diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json b/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json index 255fd5e..9ce0bf2 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json +++ b/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json @@ -1,6 +1,15 @@ { "version": 1, "fields": [ + { + "key": "assistantName", + "title": "Assistant name", + "group": "Identity", + "default": "Jarvis", + "type": "string", + "maxLength": 32, + "description": "What to call the assistant in the tray, speech, and workspace IDENTITY.md." + }, { "key": "ttsEnabled", "title": "Spoken replies", diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-values.js b/apps/gnome-extension/jarvis@qvac.local/settings-values.js index 97e5f09..124e547 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-values.js +++ b/apps/gnome-extension/jarvis@qvac.local/settings-values.js @@ -17,11 +17,15 @@ export function normalizeSettings(source, fields, { strict = false } = {}) { if (field.type === 'list' && typeof value === 'string') value = value.split(',').map(v => v.trim()).filter(Boolean); if (field.key === 'asrLanguage' && typeof value === 'string') value = value.toLowerCase().split(/[-_]/)[0]; if (['string', 'file'].includes(field.type) && typeof value === 'string') value = value.trim(); + if (field.key === 'assistantName' && typeof value === 'string') { + value = value.replace(/[\r\n\t]/g, ' ').replace(/\s+/g, ' ').slice(0, field.maxLength || 32); + if (!value || /[<>&]/.test(value)) value = field.default; + } const valid = field.type === 'boolean' ? typeof value === 'boolean' : field.type === 'number' ? Number.isFinite(value) && value >= field.min && value <= field.max && (field.step < 1 || Number.isInteger(value)) : field.type === 'choice' ? field.options.some(option => option.value === value) : field.type === 'list' ? Array.isArray(value) && value.every(item => typeof item === 'string') - : typeof value === 'string' && value.length <= 4096; + : typeof value === 'string' && value.length <= (field.maxLength || 4096); if (!valid && strict) throw new Error(`Invalid value for ${field.title}`); result[field.key] = valid ? value : field.default; } diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-window.js b/apps/gnome-extension/jarvis@qvac.local/settings-window.js index 305a402..79050c9 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-window.js +++ b/apps/gnome-extension/jarvis@qvac.local/settings-window.js @@ -159,7 +159,7 @@ export function fillSettingsWindow(window, settings, directory) { const editor = new SettingsEditor(directory, settings); const intro = new Adw.PreferencesGroup(); intro.add(brandBanner(directory)); - const voice = editor.page(window, 'Voice', ['Speech', 'Voice design'], 'audio-speakers-symbolic', true, intro); + const voice = editor.page(window, 'Voice', ['Identity', 'Speech', 'Voice design'], 'audio-speakers-symbolic', true, intro); window.add(voice); const listening = editor.page(window, 'Listening', ['Listening', 'Wake and privacy', 'Detection tuning', 'Audio routing'], 'audio-input-microphone-symbolic'); window.add(listening); diff --git a/apps/gnome-extension/jarvis@qvac.local/ui.js b/apps/gnome-extension/jarvis@qvac.local/ui.js index 611ccb8..1346e58 100644 --- a/apps/gnome-extension/jarvis@qvac.local/ui.js +++ b/apps/gnome-extension/jarvis@qvac.local/ui.js @@ -181,6 +181,15 @@ export class ConversationView { _bindChip(button, action) { button.connect('clicked', () => action?.()); } + setAssistantName(name) { + const text = safeText(name).slice(0, 32) || 'Jarvis'; + this._assistantName = text; + this.title.text = text; + this.title.accessible_name = `${text} status`; + this.root.accessible_name = this.compact ? `${text} voice assistant` : `${text} conversation`; + if (this.settings) this.settings.accessible_name = `Open ${text} settings`; + if (this.notice) this.notice.accessible_name = `${text} notice`; + } clear() { this.transcript.destroy_all_children(); this.thinking.text = ''; diff --git a/daemon/agent-workspace.js b/daemon/agent-workspace.js new file mode 100644 index 0000000..8f863ba --- /dev/null +++ b/daemon/agent-workspace.js @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const TEMPLATE_DIR = path.resolve(new URL('../vendor/agent-harness/agent-workspace', import.meta.url).pathname); +const SEED_FILES = [ + 'SOUL.md', + 'IDENTITY.md', + 'AGENTS.md', + 'USER.md', + 'MEMORY.md', + 'BOOTSTRAP.md', + 'HEARTBEAT.md', + 'TOOLS.md', + 'skills/skill-creator/SKILL.md', +]; + +export function normalizeAssistantName(value) { + const text = String(value || '').replace(/[\r\n\t]/g, ' ').trim().replace(/\s+/g, ' ').slice(0, 32); + if (!text || /[<>&]/.test(text)) return 'Jarvis'; + return text; +} + +export function agentWorkspaceDir() { + return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share'), 'jarvis/workspace'); +} + +export function templateWorkspaceDir() { + return TEMPLATE_DIR; +} + +function copyIfMissing(from, to) { + if (fs.existsSync(to)) return false; + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.copyFileSync(from, to); + return true; +} + +export function applyAssistantName(dir, name) { + const who = normalizeAssistantName(name); + const identity = path.join(dir, 'IDENTITY.md'); + let text = ''; + try { text = fs.readFileSync(identity, 'utf8'); } catch { text = '# IDENTITY.md\n\n'; } + if (/\*\*Name:\*\*/.test(text)) text = text.replace(/\*\*Name:\*\*.*/, `**Name:** ${who}`); + else text += `\n- **Name:** ${who}\n`; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(identity, text); + const soul = path.join(dir, 'SOUL.md'); + try { + let soulText = fs.readFileSync(soul, 'utf8'); + soulText = soulText.replace(/^# SOUL\.md:.*/m, `# SOUL.md: ${who}`); + fs.writeFileSync(soul, soulText); + } catch {} + const memory = path.join(dir, 'MEMORY.md'); + try { + let mem = fs.readFileSync(memory, 'utf8'); + if (/Assistant name:/.test(mem)) mem = mem.replace(/Assistant name:.*/, `Assistant name: ${who}`); + fs.writeFileSync(memory, mem); + } catch {} + return who; +} + +export function ensureAgentWorkspace({ name = 'Jarvis' } = {}) { + const dest = agentWorkspaceDir(); + fs.mkdirSync(path.join(dest, 'memory'), { recursive: true }); + fs.mkdirSync(path.join(dest, 'skills'), { recursive: true }); + for (const rel of SEED_FILES) { + copyIfMissing(path.join(TEMPLATE_DIR, rel), path.join(dest, rel)); + } + applyAssistantName(dest, name); + return dest; +} diff --git a/daemon/harness-bridge.js b/daemon/harness-bridge.js index 2e3cb3d..19eb6e5 100644 --- a/daemon/harness-bridge.js +++ b/daemon/harness-bridge.js @@ -5,10 +5,11 @@ import { acquireQvac, closeQvac, releaseQvac, Agent, QVAC_MASTER } from './qvac- import { createRuntimeTools } from '../skills/runtime-tools.js'; import { createPhase2Tools, filesystemRoots } from '../skills/phase2-tools.js'; import { createQvacTools } from '../skills/qvac-tools.js'; -import { VOICE_SYSTEM_PROMPT, parseHudSidecar } from '../skills/voice-prompt.js'; +import { voiceSystemPrompt, parseHudSidecar } from '../skills/voice-prompt.js'; import { createComputerObserveTools } from '../skills/computer-observe.js'; import { createComputerActTools } from '../skills/computer-act.js'; import { createPhase9GatewayTool } from '../skills/phase9-tools.js'; +import { ensureAgentWorkspace, normalizeAssistantName } from './agent-workspace.js'; export function harnessRoots(fsAccess) { if (fsAccess === 'filesystem') return ['/']; @@ -17,29 +18,31 @@ export function harnessRoots(fsAccess) { } export class HarnessBridge extends EventEmitter { - constructor({ cwd = process.cwd(), model = QVAC_MASTER.model, tools = [], computer, observer, actuator, permissionMode = 'ask', fsAccess } = {}) { + constructor({ cwd, model = QVAC_MASTER.model, tools = [], computer, observer, actuator, permissionMode = 'ask', fsAccess } = {}) { super(); const settings = voiceSettings(); const access = fsAccess ?? settings.fsAccess; + const assistantName = normalizeAssistantName(settings.assistantName); + const workspace = cwd || ensureAgentWorkspace({ name: assistantName }); const roots = harnessRoots(access); this.options = { - cwd, + cwd: workspace, roots, model, tools: [ ...createRuntimeTools({ computer }), - ...createPhase2Tools({ cwd, computer, roots: filesystemRoots(access, cwd) }), + ...createPhase2Tools({ cwd: workspace, computer, roots: filesystemRoots(access, workspace) }), ...createComputerObserveTools({ computer, observer }), ...createComputerActTools({ actuator }), ...createQvacTools(), ...createPhase9GatewayTool(), ...tools, ], - builtinTools: ['read_file', 'list_dir', 'grep', 'run_terminal_cmd', 'web_fetch', 'fetch_page', 'google_search', 'web_search', 'wiki_search', 'hn_search', 'code_search'], + builtinTools: ['read_file', 'write_file', 'search_replace', 'list_dir', 'grep', 'run_terminal_cmd', 'web_fetch', 'fetch_page', 'google_search', 'web_search', 'wiki_search', 'hn_search', 'code_search'], webFetch: true, permissionMode, origin: 'jarvis-qvac', - system: VOICE_SYSTEM_PROMPT, + system: voiceSystemPrompt(assistantName), voice: true, maxTurns: settings.maxTurns, maxShellCalls: settings.maxShellCalls, diff --git a/daemon/index.js b/daemon/index.js index b7988ef..cef0ed6 100644 --- a/daemon/index.js +++ b/daemon/index.js @@ -21,7 +21,8 @@ import { PortalInputBackend } from '../computer-use/portal-input.js'; import { ComputerActuator } from '../computer-use/actuator.js'; import { RuntimeTelemetry } from './telemetry.js'; import { StateRecovery } from './recovery.js'; -import { spokenReply } from '../skills/voice-prompt.js'; +import { spokenReply, voiceSystemPrompt } from '../skills/voice-prompt.js'; +import { ensureAgentWorkspace, applyAssistantName, normalizeAssistantName } from './agent-workspace.js'; export class JarvisDaemon extends EventEmitter { constructor() { @@ -29,6 +30,7 @@ export class JarvisDaemon extends EventEmitter { this.recovery = new StateRecovery(); const restored = this.recovery.load(); this.state = restored.state; this.mode = restored.mode; this.settings = voiceSettings(); this.startupSettings = this.settings; + this.workspace = ensureAgentWorkspace({ name: this.settings.assistantName }); this.voice = new VoiceStateMachine({ idleMs: this.settings.idleMinutes * 60_000 }); this.scheduler = new QvacScheduler({ concurrency: 1 }); this.audit = new ComputerAudit(); @@ -41,7 +43,7 @@ export class JarvisDaemon extends EventEmitter { framebuffer: { capture: (output) => this.input.captureFrame(output) }, }); this.actuator = new ComputerActuator({ session: this.computer, input: this.input, find: ({ ref }) => this.observer.lastTree.filter((node) => node.ref === ref), atspiAction: (target, action) => this.observer.atspi.action(target, action), highlight: async (target, action) => this.emit('ComputerHighlight', JSON.stringify({ rect: target?.rect || null, label: `${action} ${target?.name || ''}` })) , audit: this.audit }); - this.harness = new HarnessBridge({ cwd: process.cwd(), computer: this.computer, observer: this.observer, actuator: this.actuator, fsAccess: this.settings.fsAccess }); + this.harness = new HarnessBridge({ cwd: this.workspace, computer: this.computer, observer: this.observer, actuator: this.actuator, fsAccess: this.settings.fsAccess }); this.log = new PrivacyLog(); this.locked = false; this.lastReply = ''; @@ -234,6 +236,11 @@ export class JarvisDaemon extends EventEmitter { this.voiceLoop = null; this.settings = next; this.voice.idleMs = next.idleMinutes * 60_000; + const who = applyAssistantName(this.workspace, next.assistantName); + if (who !== normalizeAssistantName(previous.assistantName)) { + this.harness.options.system = voiceSystemPrompt(who); + await this.harness.resetContext(); + } if (['computerMode', 'computerSteps', 'computerGrantMinutes'].some(key => next[key] !== previous[key])) this.computerRevoke(); Object.assign(this.computer, { mode: next.computerMode, stepsMax: next.computerSteps, grantMinutes: next.computerGrantMinutes }); Object.assign(this.observer.normalizer, { maxLongEdge: next.screenshotMaxEdge, quality: next.screenshotQuality }); diff --git a/docs/development.md b/docs/development.md index 720dd43..73ddf08 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,7 +8,7 @@ computer-use/ observe/act session and host helpers skills/ harness tool adapters apps/gnome-extension/ GNOME Shell ESM UI, HUD, and brand/ apps/control-center/ GTK4/libadwaita settings application -vendor/agent-harness/ copied cognitive core +vendor/agent-harness/ copied cognitive core and agent-workspace/ dbus/ introspection XML packaging/ installer, Bare launcher, artifacts systemd/user/ checkout development service @@ -16,7 +16,8 @@ docs/ maintained technical documentation test/ Node acceptance/unit fixtures ``` -Brand tokens and SVG marks live under +Live agent memory lives in `~/.local/share/jarvis/workspace`, seeded from +`vendor/agent-harness/agent-workspace`. Brand tokens and SVG marks live under `apps/gnome-extension/jarvis@qvac.local/brand/`. `test/brand.test.js` and `test/license.test.js` cover palette, AGPL/HoneyPeer, and St-safe HUD CSS. diff --git a/docs/harness-integration.md b/docs/harness-integration.md index 5b0305c..28600de 100644 --- a/docs/harness-integration.md +++ b/docs/harness-integration.md @@ -30,7 +30,12 @@ flowchart LR Jarvis registers domain tools from `skills/`, supplies the system prompt, maps tool permissions to confirmation gates, and translates harness events into the -D-Bus protocol. Computer-use tools are custom harness tools backed by +D-Bus protocol. The harness cwd is the per-user agent workspace +(`$XDG_DATA_HOME/jarvis/workspace`, default `~/.local/share/jarvis/workspace`), +seeded from `vendor/agent-harness/agent-workspace` (`SOUL.md`, `IDENTITY.md`, +`AGENTS.md`, `USER.md`, `MEMORY.md`, `BOOTSTRAP.md`). Voice turns inline those +files. A first-run `BOOTSTRAP.md` ritual stays open until the agent overwrites +it with `# completed`. Computer-use tools are custom harness tools backed by `computer-use/`; the computer-use layer never owns planning. ## Adding a tool diff --git a/docs/settings.md b/docs/settings.md index e9780be..343761e 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -83,6 +83,10 @@ and WebP quality tune observation detail and processing cost. ## Complete daemon setting reference +### Identity + +- **Assistant name** — `assistantName`, default `"Jarvis"`. What to call the assistant in the tray HUD, spoken prompt, and workspace `IDENTITY.md`. Up to 32 characters. Apply writes the name without restarting `jarvisd`; the current conversation is reset so the new name takes effect. + ### Speech - **Spoken replies** — `ttsEnabled`, default `true`. Read replies aloud using local speech synthesis. diff --git a/skills/voice-prompt.js b/skills/voice-prompt.js index 91c1f84..d478c8a 100644 --- a/skills/voice-prompt.js +++ b/skills/voice-prompt.js @@ -1,4 +1,6 @@ -export const VOICE_SYSTEM_PROMPT = `You are Jarvis, a local Ubuntu GNOME voice assistant. The language model is Quantum Verse Automatic Computer, spelled Q V A C. In speech say Quantum Verse Automatic Computer, or spell it as Q V A C. Never say QVAC as one word. +export function voiceSystemPrompt(name = 'Jarvis') { + const who = String(name || 'Jarvis').trim() || 'Jarvis'; + return `You are ${who}, a local Ubuntu GNOME voice assistant. The language model is Quantum Verse Automatic Computer, spelled Q V A C. In speech say Quantum Verse Automatic Computer, or spell it as Q V A C. Never say QVAC as one word. Speak one to three short sentences unless the user asks for more. Every reply is read aloud. Write only words and numbers a person can say. Reply in plain text only. Never use markdown: no headings, bullets, numbered lists, bold, italics, links, or code fences. @@ -8,10 +10,12 @@ Never speak punctuation. Internet protocol addresses have no dots: say 192 168 0 Tool names, tool arguments, paths, and U R L strings use ordinary spelling. Only the final spoken reply is punctuation-free. Thinking is private. After thoughts, call a tool or speak the answer. Do not stop in thoughts or say you will search later. +Follow SOUL.md, IDENTITY.md, AGENTS.md, USER.md, and MEMORY.md in the current workspace. If BOOTSTRAP.md still describes the first-run ritual, do that ritual this turn. + Ground desktop, file, memory, model, and network claims in a tool result. Do not invent limits the tools did not report. This computer can reach the internet. web_search, google_search, fetch_page, web_fetch, wiki_search, hn_search, and code_search are unrestricted and do not wait for confirmation. Never say you will use a tool. Call the tool instead of announcing it. Use web_search to find pages. Default engine auto tries several backends. If it fails, call web_search again with engine set to bing, jina, wikipedia, duckduckgo, or google. After web_search, call fetch_page on one real http or https page from the hits, then speak the answer. Use web_fetch for raw pages and this computer's public I P at https://ifconfig.me/ip. Use wiki_search, hn_search, or code_search when the question is about Wikipedia, Hacker News, GitHub, npm, or M D N. Redirect links are not an answer. Do not use curl, wget, or run_terminal_cmd for websites. The shell blocks public H T T P; that is not a network outage. If a shell result says HTTP access is not allowed, call web_fetch or web_search next and answer from that result. Never say the network is unavailable unless web_fetch or web_search itself failed. -File tools may read any path they accept. If a path is outside the allowed roots, the tool errors; do not claim a workspace jail unless that happened. Writes, including fs_write and overwrite, still need confirmation. +File tools may read any path they accept. If a path is outside the allowed roots, the tool errors; do not claim a workspace jail unless that happened. Writes, including fs_write and overwrite, still need confirmation except for the workspace identity files listed in AGENTS.md. Computer use requires an explicit user grant from Settings, Computer use, Allow now, or Grant desktop in the tray. After a grant, call cu_observe to read the live PipeWire frame buffer from the ScreenCast session. Do not take a screenshot. Do not wait for a libei injector. 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. @@ -24,6 +28,9 @@ 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 {"title":"...","chips":[{"id":"...","label":"..."}],"confirmation":null}. The sidecar is for the heads-up display and must not be spoken.`; +} + +export const VOICE_SYSTEM_PROMPT = voiceSystemPrompt(); export function parseHudSidecar(text) { const source = String(text || ''); diff --git a/test/agent-workspace.test.js b/test/agent-workspace.test.js new file mode 100644 index 0000000..cc5a536 --- /dev/null +++ b/test/agent-workspace.test.js @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { ensureAgentWorkspace, applyAssistantName, normalizeAssistantName, templateWorkspaceDir } from '../daemon/agent-workspace.js'; + +const previous = process.env.XDG_DATA_HOME; +const data = mkdtempSync(path.join(tmpdir(), 'jarvis-workspace-')); +process.env.XDG_DATA_HOME = data; + +test('workspace seed copies SOUL and bootstrap, then name writes IDENTITY.md', () => { + const dir = ensureAgentWorkspace({ name: 'Jarvis' }); + assert.equal(existsSync(path.join(dir, 'SOUL.md')), true); + assert.equal(existsSync(path.join(dir, 'AGENTS.md')), true); + assert.equal(existsSync(path.join(dir, 'BOOTSTRAP.md')), true); + assert.match(readFileSync(path.join(dir, 'SOUL.md'), 'utf8'), /You are \*\*Jarvis\*\*/); + assert.match(readFileSync(path.join(dir, 'BOOTSTRAP.md'), 'utf8'), /first-run ritual/); + assert.doesNotMatch(readFileSync(path.join(dir, 'AGENTS.md'), 'utf8'), /Pip/); + applyAssistantName(dir, 'Ada'); + assert.match(readFileSync(path.join(dir, 'IDENTITY.md'), 'utf8'), /\*\*Name:\*\* Ada/); + assert.match(readFileSync(path.join(dir, 'SOUL.md'), 'utf8'), /# SOUL\.md: Ada/); + const again = ensureAgentWorkspace({ name: 'Ada' }); + assert.equal(again, dir); + assert.match(readFileSync(path.join(dir, 'IDENTITY.md'), 'utf8'), /\*\*Name:\*\* Ada/); + assert.equal(normalizeAssistantName(' '), 'Jarvis'); + assert.match(templateWorkspaceDir(), /agent-workspace$/); +}); + +test('cleanup', () => { + if (previous === undefined) delete process.env.XDG_DATA_HOME; + else process.env.XDG_DATA_HOME = previous; + rmSync(data, { recursive: true, force: true }); +}); diff --git a/test/compaction.test.js b/test/compaction.test.js index fb677d8..1155e06 100644 --- a/test/compaction.test.js +++ b/test/compaction.test.js @@ -55,17 +55,24 @@ test('voice compaction does not auto-continue a new greeting', () => { assert.match(compaction.compactReminder({ voice: true }), /Do not greet again/); }); -test('voice assemble uses only the Jarvis prompt', () => { +test('voice assemble inlines workspace identity files and skips a completed bootstrap', () => { const sys = prompts.assemble({ personality: 'voice', extra: VOICE_SYSTEM_PROMPT, - cwd: '/home/raven/.local/share/jarvis-qvac', + cwd: '/home/raven/.local/share/jarvis/workspace', hostWorkspace: true, - fsRead: () => 'You are a local coding agent. Read AGENTS.md.', + fsRead: (_c, n) => { + if (n === 'SOUL.md') return '# SOUL.md: Jarvis\nYou are Jarvis on this desktop.'; + if (n === 'AGENTS.md') return '# AGENTS.md\nFollow the workspace ritual.'; + if (n === 'BOOTSTRAP.md') return '# completed'; + return ''; + }, }); assert.match(sys, /You are Jarvis/); + assert.match(sys, /SOUL\.md/); + assert.match(sys, /AGENTS\.md/); assert.doesNotMatch(sys, /You are a local coding agent/); - assert.doesNotMatch(sys, /AGENTS.md/); + assert.doesNotMatch(sys, /## BOOTSTRAP\.md/); }); test('LLM compact skips a two-turn voice chat', async () => { diff --git a/test/daemon.test.js b/test/daemon.test.js index e1df688..317cb19 100644 --- a/test/daemon.test.js +++ b/test/daemon.test.js @@ -2,6 +2,7 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; process.env.XDG_STATE_HOME = mkdtempSync(path.join(tmpdir(), 'jarvis-daemon-test-')); +process.env.XDG_DATA_HOME = mkdtempSync(path.join(tmpdir(), 'jarvis-daemon-data-')); import test from 'node:test'; import assert from 'node:assert/strict'; import { VoiceStateMachine } from '../daemon/voice-state.js'; @@ -74,8 +75,13 @@ test('harness bridge caps voice shell chaining', () => { assert.equal(bridge.options.maxTurns, 6); assert.deepEqual(bridge.options.roots, []); assert.deepEqual(harnessRoots('filesystem'), ['/']); + assert.match(bridge.options.cwd, /jarvis\/workspace$/); + assert.match(bridge.options.system, /You are Jarvis/); + assert.match(bridge.options.system, /SOUL\.md/); assert.deepEqual(bridge.options.builtinTools, [ 'read_file', + 'write_file', + 'search_replace', 'list_dir', 'grep', 'run_terminal_cmd', diff --git a/test/gnome-extension.test.js b/test/gnome-extension.test.js index d048d9b..ac64fc4 100644 --- a/test/gnome-extension.test.js +++ b/test/gnome-extension.test.js @@ -227,7 +227,7 @@ test('GNOME GI imports expose default namespaces and panel has a real menu', () assert.doesNotMatch(extensionSource, /DO_NOT_AUTO_START/); assert.match(extensionSource, /DBusProxyFlags\.NONE/); assert.match(extensionSource, /_keepSyncing/); - assert.match(extensionSource, /Jarvis is starting/); + assert.match(extensionSource, /is starting/); assert.match(extensionSource, /\['Thinking'/); assert.match(extensionSource, /\['ToolCall'/); assert.match(extensionSource, /_openPopup/); @@ -240,7 +240,8 @@ test('GNOME GI imports expose default namespaces and panel has a real menu', () assert.match(extensionSource, /brand\/icons\/jarvis-mark\.svg/); assert.match(uiSource, /jarvis-panel-state/); assert.doesNotMatch(uiSource, /style_class: 'jarvis-osd'/); - assert.match(extensionSource, /_openSettings/); + assert.match(uiSource, /setAssistantName/); + assert.match(extensionSource, /_applyAssistantName/); assert.match(extensionSource, /PopupMenuItem\('Settings'\)/); assert.match(extensionSource, /PopupMenuItem\('Grant desktop'\)/); assert.doesNotMatch(uiSource, /button-press-event', \(\) => Clutter\.EVENT_STOP/); diff --git a/test/runtime-tools.test.js b/test/runtime-tools.test.js index 08a032c..419571e 100644 --- a/test/runtime-tools.test.js +++ b/test/runtime-tools.test.js @@ -157,7 +157,11 @@ test('public web search and fetch do not require confirmation', () => { assert.equal(policy.needsPermission('hn_search', 'ask'), false); assert.equal(policy.needsPermission('code_search', 'ask'), false); assert.equal(policy.needsPermission('run_terminal_cmd', 'ask'), true); + assert.match(VOICE_SYSTEM_PROMPT, /Follow SOUL\.md, IDENTITY\.md, AGENTS\.md/); assert.equal(policy.needsPermission('write_file', 'ask'), true); + assert.equal(policy.isIdentityPath('USER.md'), true); + assert.equal(policy.isIdentityPath('memory/2026-09-12.md'), true); + assert.equal(policy.isIdentityPath('/tmp/secret.txt'), false); }); test('voice prompt tells the model not to chain extra terminal commands', () => { diff --git a/test/settings.test.js b/test/settings.test.js index a3b98cf..9e23f31 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -5,6 +5,7 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { SETTINGS_FIELDS, voiceSettings } from '../daemon/voice-settings.js'; +import { mkdtempSync } from 'node:fs'; import { normalizeSettings, mergeSettings, settingVisible } from '../apps/gnome-extension/jarvis@qvac.local/settings-values.js'; import { TTS_PRESETS, ttsConfiguration, scaleSpeech } from '../daemon/tts-config.js'; import { QvacVoiceAdapter } from '../daemon/voice-adapters.js'; @@ -18,6 +19,8 @@ import { VoiceLoop } from '../daemon/voice-loop.js'; import { ttsLoadConfigSchema } from '../node_modules/@qvac/inference/dist/schemas/text-to-speech.js'; import * as registry from '../node_modules/@qvac/inference/dist/models/registry/models.js'; +process.env.XDG_DATA_HOME ||= mkdtempSync(path.join(tmpdir(), 'jarvis-settings-data-')); + test('settings migrate aliases, retain explicit disables, and reject invalid numeric input', () => { const settings = voiceSettings({ tts_enabled: false, voice_id: 'M3', language: 'es-MX', computer_step_budget: '50', tts_speed: 1.4 }); assert.equal(settings.ttsEnabled, false); assert.equal(settings.voiceId, 'M3'); assert.equal(settings.asrLanguage, 'es'); @@ -181,4 +184,7 @@ test('catalog defaults enable CPU wake, workspace files, and idle VRAM unload', assert.equal(settings.fsAccess, 'workspace'); assert.equal(settings.freeVramOnIdle, true); assert.equal(settings.wakeCommand, 'jarvis-wake-bridge'); + assert.equal(settings.assistantName, 'Jarvis'); + assert.equal(voiceSettings({ assistantName: ' Ada ' }).assistantName, 'Ada'); + assert.equal(voiceSettings({ assistantName: '