Personality controls in settings
Rolling release / release (push) Successful in 7m29s

This commit is contained in:
2026-09-12 15:56:35 -04:00
parent 5317576087
commit 2f6e4b3af9
19 changed files with 104 additions and 13 deletions
@@ -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;
}
@@ -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",
@@ -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));
@@ -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;
+14 -1
View File
@@ -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;
}
+2 -2
View File
@@ -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,
+5 -4
View File
@@ -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();
+1 -1
View File
@@ -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.
+1
View File
@@ -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
+6 -2
View File
@@ -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.
+5 -2
View File
@@ -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$/);
});
+3
View File
@@ -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',
+7 -1
View File
@@ -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);
});
+2
View File
@@ -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: '<script>' }).assistantName, 'Jarvis');
assert.equal(voiceSettings({ assistantPrompt: 'Be dry.\nSkip filler. ' }).assistantPrompt, 'Be dry.\nSkip filler.');
});
+1
View File
@@ -10,6 +10,7 @@ On a **new chat**, recall who you are talking to before you speak:
1. Use `USER.md` if it has their name.
2. If the name is missing, `read_file` `USER.md` and `MEMORY.md` (and todays `memory/YYYY-MM-DD.md` if present). Then greet them by name.
3. If those files still have no name, ask once and write `USER.md`.
4. Follow `PERSONA.md` for user-authored acting notes. Settings is the source of truth for that file.
Do not invent a name. Skills: only the catalog is inlined. `read_file` the matching `SKILL.md` when a task fits.
+3
View File
@@ -0,0 +1,3 @@
# PERSONA.md
User-authored notes for how the assistant should act. Settings → Voice → How they should act is the source of truth. Leave this empty if there are no extra notes.
+1
View File
@@ -42,6 +42,7 @@ Each chat session you wake up fresh. These workspace files _are_ you:
- `IDENTITY.md`: name and face
- `AGENTS.md`: how you operate
- `USER.md`: who youre helping
- `PERSONA.md`: extra acting notes from Settings
- `MEMORY.md` and `memory/YYYY-MM-DD.md`: what youve learned
Read them. Update them when something durable happens. That is how you persist.
+1
View File
@@ -19,6 +19,7 @@ const IDENTITY_FILES = new Set([
'bootstrap.md',
'heartbeat.md',
'tools.md',
'persona.md',
]);
function isIdentityPath(filePath) {
+2
View File
@@ -26,12 +26,14 @@ const VOICE_WORKSPACE_FILES = [
'BOOTSTRAP.md',
'TOOLS.md',
'HEARTBEAT.md',
'PERSONA.md',
];
function includeWorkspaceFile(name, text) {
const body = String(text || '').trim();
if (!body) return false;
if (name === 'BOOTSTRAP.md' && /^# completed/i.test(body)) return false;
if (name === 'PERSONA.md' && !body.replace(/^# PERSONA\.md\s*/i, '').trim()) return false;
return true;
}