This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
|
||||
@@ -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 today’s `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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 you’re helping
|
||||
- `PERSONA.md`: extra acting notes from Settings
|
||||
- `MEMORY.md` and `memory/YYYY-MM-DD.md`: what you’ve learned
|
||||
|
||||
Read them. Update them when something durable happens. That is how you persist.
|
||||
|
||||
Vendored
+1
@@ -19,6 +19,7 @@ const IDENTITY_FILES = new Set([
|
||||
'bootstrap.md',
|
||||
'heartbeat.md',
|
||||
'tools.md',
|
||||
'persona.md',
|
||||
]);
|
||||
|
||||
function isIdentityPath(filePath) {
|
||||
|
||||
+2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user