From 2f6e4b3af917352187692fa4195954e2ccda74f7 Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Sat, 12 Sep 2026 15:56:35 -0400 Subject: [PATCH] Personality controls in settings --- .../jarvis@qvac.local/brand/gtk.css | 5 +++ .../jarvis@qvac.local/settings-catalog.json | 9 ++++++ .../jarvis@qvac.local/settings-editor.js | 32 +++++++++++++++++++ .../jarvis@qvac.local/settings-values.js | 4 +++ daemon/agent-workspace.js | 15 ++++++++- daemon/harness-bridge.js | 4 +-- daemon/index.js | 9 +++--- docs/harness-integration.md | 2 +- docs/settings.md | 1 + skills/voice-prompt.js | 8 +++-- test/agent-workspace.test.js | 7 ++-- test/daemon.test.js | 3 ++ test/runtime-tools.test.js | 8 ++++- test/settings.test.js | 2 ++ .../agent-harness/agent-workspace/AGENTS.md | 1 + .../agent-harness/agent-workspace/PERSONA.md | 3 ++ vendor/agent-harness/agent-workspace/SOUL.md | 1 + vendor/agent-harness/agent/policy.js | 1 + vendor/agent-harness/agent/prompts.js | 2 ++ 19 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 vendor/agent-harness/agent-workspace/PERSONA.md diff --git a/apps/gnome-extension/jarvis@qvac.local/brand/gtk.css b/apps/gnome-extension/jarvis@qvac.local/brand/gtk.css index 292f74c..0bcb623 100644 --- a/apps/gnome-extension/jarvis@qvac.local/brand/gtk.css +++ b/apps/gnome-extension/jarvis@qvac.local/brand/gtk.css @@ -38,3 +38,8 @@ button.jarvis-swatch:checked, button.jarvis-swatch:active { box-shadow: 0 0 0 2px @jarvis_ink, 0 0 0 4px @jarvis_gold; } + +textview.jarvis-prompt { + background: @jarvis_ink; + color: #F6F7FB; +} diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json b/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json index 9ce0bf2..69ba214 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json +++ b/apps/gnome-extension/jarvis@qvac.local/settings-catalog.json @@ -10,6 +10,15 @@ "maxLength": 32, "description": "What to call the assistant in the tray, speech, and workspace IDENTITY.md." }, + { + "key": "assistantPrompt", + "title": "How they should act", + "group": "Identity", + "default": "", + "type": "text", + "maxLength": 4000, + "description": "Optional extra instructions: tone, habits, jokes, things to always do or avoid. Spoken safety rules still win." + }, { "key": "ttsEnabled", "title": "Spoken replies", diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-editor.js b/apps/gnome-extension/jarvis@qvac.local/settings-editor.js index 26b66e2..dc2d8b9 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-editor.js +++ b/apps/gnome-extension/jarvis@qvac.local/settings-editor.js @@ -123,6 +123,38 @@ export class SettingsEditor { clear.connect('clicked', () => { changed(''); row.subtitle = field.description; }); row.add_suffix(choose); row.add_suffix(clear); row._setValue = value => { row.subtitle = value || field.description; }; + } else if (field.type === 'text') { + const box = new Gtk.Box({ orientation: Gtk.Orientation.VERTICAL, spacing: 6, margin_start: 12, margin_end: 12, margin_top: 6, margin_bottom: 8 }); + const title = new Gtk.Label({ label: field.title, xalign: 0, wrap: true }); + title.add_css_class('heading'); + const help = new Gtk.Label({ label: field.description, wrap: true, xalign: 0 }); + help.add_css_class('dim-label'); + const view = new Gtk.TextView({ wrap_mode: Gtk.WrapMode.WORD_CHAR, accepts_tab: false, left_margin: 8, right_margin: 8, top_margin: 8, bottom_margin: 8 }); + view.add_css_class('jarvis-prompt'); + const text = String(this.values[field.key] || ''); + view.buffer.set_text(text, -1); + view.buffer.connect('changed', () => { + const buffer = view.buffer; + changed(buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), false)); + }); + const scroll = new Gtk.ScrolledWindow({ + min_content_height: 140, + max_content_height: 220, + hexpand: true, + vexpand: false, + has_frame: true, + propagate_natural_height: true, + }); + scroll.set_child(view); + box.append(title); + box.append(help); + box.append(scroll); + box._setValue = value => { + const next = String(value || ''); + const buffer = view.buffer; + if (buffer.get_text(buffer.get_start_iter(), buffer.get_end_iter(), false) !== next) buffer.set_text(next, -1); + }; + row = box; } else { row = new Adw.EntryRow({ title: field.title, tooltip_text: field.description, text: field.type === 'list' ? this.values[field.key].join(', ') : this.values[field.key] }); row.connect('changed', () => changed(field.type === 'list' ? row.text.split(',').map(v => v.trim()).filter(Boolean) : row.text)); diff --git a/apps/gnome-extension/jarvis@qvac.local/settings-values.js b/apps/gnome-extension/jarvis@qvac.local/settings-values.js index 124e547..22acb77 100644 --- a/apps/gnome-extension/jarvis@qvac.local/settings-values.js +++ b/apps/gnome-extension/jarvis@qvac.local/settings-values.js @@ -21,10 +21,14 @@ export function normalizeSettings(source, fields, { strict = false } = {}) { value = value.replace(/[\r\n\t]/g, ' ').replace(/\s+/g, ' ').slice(0, field.maxLength || 32); if (!value || /[<>&]/.test(value)) value = field.default; } + if (field.type === 'text' && typeof value === 'string') { + value = value.replace(/\0/g, '').replace(/\s+$/g, '').slice(0, field.maxLength || 4000); + } 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') + : field.type === 'text' ? typeof value === 'string' && value.length <= (field.maxLength || 4000) : 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/daemon/agent-workspace.js b/daemon/agent-workspace.js index 8f863ba..bea5b1c 100644 --- a/daemon/agent-workspace.js +++ b/daemon/agent-workspace.js @@ -12,6 +12,7 @@ const SEED_FILES = [ 'BOOTSTRAP.md', 'HEARTBEAT.md', 'TOOLS.md', + 'PERSONA.md', 'skills/skill-creator/SKILL.md', ]; @@ -60,7 +61,18 @@ export function applyAssistantName(dir, name) { return who; } -export function ensureAgentWorkspace({ name = 'Jarvis' } = {}) { +export function applyAssistantPrompt(dir, prompt) { + const notes = String(prompt || '').replace(/\0/g, '').replace(/\s+$/g, '').slice(0, 4000); + const file = path.join(dir, 'PERSONA.md'); + const body = notes + ? `# PERSONA.md\n\n${notes}\n` + : '# PERSONA.md\n\n'; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file, body); + return notes; +} + +export function ensureAgentWorkspace({ name = 'Jarvis', prompt = '' } = {}) { const dest = agentWorkspaceDir(); fs.mkdirSync(path.join(dest, 'memory'), { recursive: true }); fs.mkdirSync(path.join(dest, 'skills'), { recursive: true }); @@ -68,5 +80,6 @@ export function ensureAgentWorkspace({ name = 'Jarvis' } = {}) { copyIfMissing(path.join(TEMPLATE_DIR, rel), path.join(dest, rel)); } applyAssistantName(dest, name); + applyAssistantPrompt(dest, prompt); return dest; } diff --git a/daemon/harness-bridge.js b/daemon/harness-bridge.js index 19eb6e5..5e86ce9 100644 --- a/daemon/harness-bridge.js +++ b/daemon/harness-bridge.js @@ -23,7 +23,7 @@ export class HarnessBridge extends EventEmitter { const settings = voiceSettings(); const access = fsAccess ?? settings.fsAccess; const assistantName = normalizeAssistantName(settings.assistantName); - const workspace = cwd || ensureAgentWorkspace({ name: assistantName }); + const workspace = cwd || ensureAgentWorkspace({ name: assistantName, prompt: settings.assistantPrompt }); const roots = harnessRoots(access); this.options = { cwd: workspace, @@ -42,7 +42,7 @@ export class HarnessBridge extends EventEmitter { webFetch: true, permissionMode, origin: 'jarvis-qvac', - system: voiceSystemPrompt(assistantName), + system: voiceSystemPrompt(assistantName, settings.assistantPrompt), voice: true, maxTurns: settings.maxTurns, maxShellCalls: settings.maxShellCalls, diff --git a/daemon/index.js b/daemon/index.js index cef0ed6..f2efb3d 100644 --- a/daemon/index.js +++ b/daemon/index.js @@ -22,7 +22,7 @@ import { ComputerActuator } from '../computer-use/actuator.js'; import { RuntimeTelemetry } from './telemetry.js'; import { StateRecovery } from './recovery.js'; import { spokenReply, voiceSystemPrompt } from '../skills/voice-prompt.js'; -import { ensureAgentWorkspace, applyAssistantName, normalizeAssistantName } from './agent-workspace.js'; +import { ensureAgentWorkspace, applyAssistantName, applyAssistantPrompt, normalizeAssistantName } from './agent-workspace.js'; export class JarvisDaemon extends EventEmitter { constructor() { @@ -30,7 +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.workspace = ensureAgentWorkspace({ name: this.settings.assistantName, prompt: this.settings.assistantPrompt }); this.voice = new VoiceStateMachine({ idleMs: this.settings.idleMinutes * 60_000 }); this.scheduler = new QvacScheduler({ concurrency: 1 }); this.audit = new ComputerAudit(); @@ -237,8 +237,9 @@ export class JarvisDaemon extends EventEmitter { 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); + applyAssistantPrompt(this.workspace, next.assistantPrompt); + if (who !== normalizeAssistantName(previous.assistantName) || String(next.assistantPrompt || '') !== String(previous.assistantPrompt || '')) { + this.harness.options.system = voiceSystemPrompt(who, next.assistantPrompt); await this.harness.resetContext(); } if (['computerMode', 'computerSteps', 'computerGrantMinutes'].some(key => next[key] !== previous[key])) this.computerRevoke(); diff --git a/docs/harness-integration.md b/docs/harness-integration.md index 28600de..024bb2a 100644 --- a/docs/harness-integration.md +++ b/docs/harness-integration.md @@ -33,7 +33,7 @@ tool permissions to confirmation gates, and translates harness events into the 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 +`AGENTS.md`, `USER.md`, `MEMORY.md`, `BOOTSTRAP.md`, `PERSONA.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. diff --git a/docs/settings.md b/docs/settings.md index 343761e..cdb40f8 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -86,6 +86,7 @@ and WebP quality tune observation detail and processing cost. ### 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. +- **How they should act** — `assistantPrompt`, default `""`. Optional extra personality instructions (tone, habits, always/never). Shown as a multiline field under the name. Apply writes `PERSONA.md` and resets the conversation. Spoken safety rules still win. Up to 4000 characters. ### Speech diff --git a/skills/voice-prompt.js b/skills/voice-prompt.js index d478c8a..38aaa80 100644 --- a/skills/voice-prompt.js +++ b/skills/voice-prompt.js @@ -1,5 +1,9 @@ -export function voiceSystemPrompt(name = 'Jarvis') { +export function voiceSystemPrompt(name = 'Jarvis', extra = '') { const who = String(name || 'Jarvis').trim() || 'Jarvis'; + const notes = String(extra || '').replace(/\0/g, '').trim(); + const persona = notes + ? `\n\nUser personality notes. Follow these when they do not conflict with speech or safety rules:\n${notes}` + : ''; 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. @@ -10,7 +14,7 @@ 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. +Follow SOUL.md, IDENTITY.md, AGENTS.md, USER.md, MEMORY.md, and PERSONA.md in the current workspace. If BOOTSTRAP.md still describes the first-run ritual, do that ritual this turn.${persona} 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. diff --git a/test/agent-workspace.test.js b/test/agent-workspace.test.js index cc5a536..7234882 100644 --- a/test/agent-workspace.test.js +++ b/test/agent-workspace.test.js @@ -3,7 +3,7 @@ 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'; +import { ensureAgentWorkspace, applyAssistantName, applyAssistantPrompt, normalizeAssistantName, templateWorkspaceDir } from '../daemon/agent-workspace.js'; const previous = process.env.XDG_DATA_HOME; const data = mkdtempSync(path.join(tmpdir(), 'jarvis-workspace-')); @@ -23,7 +23,10 @@ test('workspace seed copies SOUL and bootstrap, then name writes IDENTITY.md', ( const again = ensureAgentWorkspace({ name: 'Ada' }); assert.equal(again, dir); assert.match(readFileSync(path.join(dir, 'IDENTITY.md'), 'utf8'), /\*\*Name:\*\* Ada/); - assert.equal(normalizeAssistantName(' '), 'Jarvis'); + applyAssistantPrompt(dir, 'Be a dry ship cook. Never say great question.'); + assert.match(readFileSync(path.join(dir, 'PERSONA.md'), 'utf8'), /dry ship cook/); + applyAssistantPrompt(dir, ''); + assert.equal(readFileSync(path.join(dir, 'PERSONA.md'), 'utf8').trim(), '# PERSONA.md'); assert.match(templateWorkspaceDir(), /agent-workspace$/); }); diff --git a/test/daemon.test.js b/test/daemon.test.js index 317cb19..6560f05 100644 --- a/test/daemon.test.js +++ b/test/daemon.test.js @@ -3,6 +3,7 @@ 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-')); +process.env.XDG_CONFIG_HOME = mkdtempSync(path.join(tmpdir(), 'jarvis-daemon-config-')); import test from 'node:test'; import assert from 'node:assert/strict'; import { VoiceStateMachine } from '../daemon/voice-state.js'; @@ -78,6 +79,8 @@ test('harness bridge caps voice shell chaining', () => { assert.match(bridge.options.cwd, /jarvis\/workspace$/); assert.match(bridge.options.system, /You are Jarvis/); assert.match(bridge.options.system, /SOUL\.md/); + assert.match(bridge.options.system, /PERSONA\.md/); + assert.doesNotMatch(bridge.options.system, /User personality notes/); assert.deepEqual(bridge.options.builtinTools, [ 'read_file', 'write_file', diff --git a/test/runtime-tools.test.js b/test/runtime-tools.test.js index 419571e..221b936 100644 --- a/test/runtime-tools.test.js +++ b/test/runtime-tools.test.js @@ -3,7 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createRuntimeTools } from '../skills/runtime-tools.js'; import { assertSdkVersion } from '../daemon/qvac-master.js'; -import { parseHudSidecar, VOICE_SYSTEM_PROMPT } from '../skills/voice-prompt.js'; +import { parseHudSidecar, VOICE_SYSTEM_PROMPT, voiceSystemPrompt } from '../skills/voice-prompt.js'; import { createPhase2Tools } from '../skills/phase2-tools.js'; import { createQvacTools } from '../skills/qvac-tools.js'; import { profile } from '../daemon/model-profiles.js'; @@ -158,8 +158,14 @@ test('public web search and fetch do not require confirmation', () => { 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.match(VOICE_SYSTEM_PROMPT, /PERSONA\.md/); + assert.doesNotMatch(VOICE_SYSTEM_PROMPT, /User personality notes/); + assert.match(voiceSystemPrompt('Ada', 'Be dry. Skip filler.'), /You are Ada/); + assert.match(voiceSystemPrompt('Ada', 'Be dry. Skip filler.'), /User personality notes/); + assert.match(voiceSystemPrompt('Ada', 'Be dry. Skip filler.'), /Be dry\. Skip filler\./); assert.equal(policy.needsPermission('write_file', 'ask'), true); assert.equal(policy.isIdentityPath('USER.md'), true); + assert.equal(policy.isIdentityPath('PERSONA.md'), true); assert.equal(policy.isIdentityPath('memory/2026-09-12.md'), true); assert.equal(policy.isIdentityPath('/tmp/secret.txt'), false); }); diff --git a/test/settings.test.js b/test/settings.test.js index 9e23f31..6c19989 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -185,6 +185,8 @@ test('catalog defaults enable CPU wake, workspace files, and idle VRAM unload', assert.equal(settings.freeVramOnIdle, true); assert.equal(settings.wakeCommand, 'jarvis-wake-bridge'); assert.equal(settings.assistantName, 'Jarvis'); + assert.equal(settings.assistantPrompt, ''); assert.equal(voiceSettings({ assistantName: ' Ada ' }).assistantName, 'Ada'); assert.equal(voiceSettings({ assistantName: '