Updates
Rolling release / release (push) Successful in 7m29s

This commit is contained in:
2026-09-12 16:46:49 -04:00
parent 04a9306594
commit 4383763cb5
20 changed files with 490 additions and 26 deletions
@@ -17,7 +17,7 @@
"default": "",
"type": "text",
"maxLength": 4000,
"description": "Optional extra instructions: tone, habits, jokes, things to always do or avoid. Spoken safety rules still win."
"description": "Character instructions go in the system prompt only. They override SOUL.md and IDENTITY.md for name and tone. Replies stay speakable."
},
{
"key": "ttsEnabled",
@@ -704,6 +704,10 @@
"type": "choice",
"description": "Requires a daemon restart. GPU inference remains required.",
"options": [
{
"value": "laptop-4gb-mm",
"label": "Tiny \u00b7 Qwen3.5 0.8B"
},
{
"value": "laptop-8gb",
"label": "Small \u00b7 Qwen3 1.7B"
+13 -2
View File
@@ -37,24 +37,35 @@ function copyIfMissing(from, to) {
return true;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function renameAssistant(text, previous, next) {
if (!previous || previous === next) return text;
return String(text || '').replace(new RegExp(escapeRegExp(previous), 'g'), next);
}
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'; }
const previous = (text.match(/\*\*Name:\*\*\s*(.+)/) || [])[1]?.trim() || 'Jarvis';
text = renameAssistant(text, previous, who);
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');
let soulText = renameAssistant(fs.readFileSync(soul, 'utf8'), previous, who);
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');
let mem = renameAssistant(fs.readFileSync(memory, 'utf8'), previous, who);
if (/Assistant name:/.test(mem)) mem = mem.replace(/Assistant name:.*/, `Assistant name: ${who}`);
fs.writeFileSync(memory, mem);
} catch {}
+1
View File
@@ -1,4 +1,5 @@
export const MODEL_PROFILES = Object.freeze({
'laptop-4gb-mm': { model: 'qwen3.5-0.8b', minRamGb: 4, vision: true },
'laptop-8gb': { model: 'qwen3-1.7b', minRamGb: 8, vision: false },
'laptop-8gb-mm': { model: 'qwen3.5-2b', minRamGb: 6, vision: true },
'laptop-16gb': { model: 'qwen3.5-4b', minRamGb: 10, vision: true },
+5 -2
View File
@@ -33,8 +33,11 @@ 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`, `PERSONA.md`). Voice turns inline those
files. A first-run `BOOTSTRAP.md` ritual stays open until the agent overwrites
`AGENTS.md`, `USER.md`, `MEMORY.md`, `BOOTSTRAP.md`, `PERSONA.md`). Voice turns
inline workspace files into the system prompt. Settings **How they should act**
notes are injected as a `## Acting` block in that system prompt only; they are
not also inlined from `PERSONA.md`, and they override `SOUL.md` / `IDENTITY.md`
for name and character. 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.
+2 -2
View File
@@ -86,7 +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.
- **How they should act** — `assistantPrompt`, default `""`. Optional character instructions (tone, habits, always/never). Shown as a multiline field under the name. Apply writes `PERSONA.md` for the workspace copy and puts the notes in the **system prompt only**, where they override `SOUL.md` / `IDENTITY.md` for name and character. Speakable-text rules still apply so replies can be read aloud. Up to 4000 characters.
### Speech
@@ -151,7 +151,7 @@ and WebP quality tune observation detail and processing cost.
### Chat model
- **Chat model profile** — `modelProfile`, default `"laptop-16gb"`. Requires a daemon restart. GPU inference remains required. Choices: `laptop-8gb`, `laptop-8gb-mm` (Qwen3.5 2B, vision), `laptop-16gb`, `desktop-gpu`.
- **Chat model profile** — `modelProfile`, default `"laptop-16gb"`. Requires a daemon restart. GPU inference remains required. Choices: `laptop-4gb-mm` (Qwen3.5 0.8B, vision), `laptop-8gb`, `laptop-8gb-mm` (Qwen3.5 2B, vision), `laptop-16gb`, `desktop-gpu`.
- **Custom chat model** — `chatModel`, default `""`. Advanced: overrides the profile. Empty uses the profile model. Requires restart.
### Agent limits
+20 -6
View File
@@ -1,10 +1,20 @@
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.
const identity = notes
? `Your name is ${who}. Answer only as ${who}. Never call yourself Jarvis unless your name is Jarvis.
## Acting
${notes}
The acting instructions are your identity. They override SOUL.md, IDENTITY.md, and PERSONA.md for name, character, and tone. If those instructions address Jarvis, they mean you; still speak as ${who}.`
: `You are ${who}, a local Ubuntu GNOME voice assistant.`;
const followFiles = notes
? `Follow AGENTS.md, USER.md, MEMORY.md, and TOOLS.md in the current workspace for how this machine works. Do not take your name or personality from SOUL.md, IDENTITY.md, or PERSONA.md. If BOOTSTRAP.md still describes the first-run ritual, do that ritual this turn as ${who}.`
: `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.`;
return `${identity}
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.
@@ -14,7 +24,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, MEMORY.md, and PERSONA.md in the current workspace. If BOOTSTRAP.md still describes the first-run ritual, do that ritual this turn.${persona}
${followFiles}
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.
@@ -52,5 +62,9 @@ export function spokenReply(reply) {
: typeof reply.message === 'string' ? reply.message
: '';
if (!text || text === '[object Object]') return '';
return parseHudSidecar(text).spoken;
const stripped = String(text)
.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '')
.replace(/<function\s*=[^>]*>[\s\S]*?<\/function>/gi, '')
.replace(/<tool_call>[\s\S]*$/gi, '');
return parseHudSidecar(stripped).spoken;
}
+3
View File
@@ -19,7 +19,10 @@ test('workspace seed copies SOUL and bootstrap, then name writes IDENTITY.md', (
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, 'IDENTITY.md'), 'utf8'), /Ada lives on this Ubuntu/);
assert.match(readFileSync(path.join(dir, 'SOUL.md'), 'utf8'), /# SOUL\.md: Ada/);
assert.match(readFileSync(path.join(dir, 'SOUL.md'), 'utf8'), /You are \*\*Ada\*\*/);
assert.doesNotMatch(readFileSync(path.join(dir, 'SOUL.md'), 'utf8'), /You are \*\*Jarvis\*\*/);
const again = ensureAgentWorkspace({ name: 'Ada' });
assert.equal(again, dir);
assert.match(readFileSync(path.join(dir, 'IDENTITY.md'), 'utf8'), /\*\*Name:\*\* Ada/);
+30 -1
View File
@@ -1,7 +1,7 @@
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';
import { VOICE_SYSTEM_PROMPT, voiceSystemPrompt } from '../skills/voice-prompt.js';
const require = createRequire(import.meta.url);
const compaction = require('../vendor/agent-harness/agent/compaction.js');
@@ -64,6 +64,7 @@ test('voice assemble inlines workspace identity files and skips a completed boot
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 === 'PERSONA.md') return '# PERSONA.md\nBe a roast comedian.';
if (n === 'BOOTSTRAP.md') return '# completed';
return '';
},
@@ -71,10 +72,38 @@ test('voice assemble inlines workspace identity files and skips a completed boot
assert.match(sys, /You are Jarvis/);
assert.match(sys, /SOUL\.md/);
assert.match(sys, /AGENTS\.md/);
assert.doesNotMatch(sys, /## PERSONA\.md/);
assert.doesNotMatch(sys, /Be a roast comedian/);
assert.doesNotMatch(sys, /You are a local coding agent/);
assert.doesNotMatch(sys, /## BOOTSTRAP\.md/);
});
test('voice assemble keeps acting notes in the system prompt and skips SOUL identity files', () => {
const extra = voiceSystemPrompt('Tony', 'Jarvis, you are acting as Tony Hinchcliffe. Never call yourself Jarvis.');
const sys = prompts.assemble({
personality: 'voice',
extra,
cwd: '/home/raven/.local/share/jarvis/workspace',
hostWorkspace: true,
fsRead: (_c, n) => {
if (n === 'SOUL.md') return '# SOUL.md: Jarvis\nYou are **Jarvis**.';
if (n === 'IDENTITY.md') return '# IDENTITY.md\nJarvis lives here.';
if (n === 'PERSONA.md') return '# PERSONA.md\nDuplicate roast notes.';
if (n === 'AGENTS.md') return '# AGENTS.md\nFollow the workspace ritual.';
return '';
},
});
assert.match(sys, /Your name is Tony/);
assert.match(sys, /## Acting/);
assert.match(sys, /Tony Hinchcliffe/);
assert.match(sys, /AGENTS\.md/);
assert.doesNotMatch(sys, /## SOUL\.md/);
assert.doesNotMatch(sys, /## IDENTITY\.md/);
assert.doesNotMatch(sys, /## PERSONA\.md/);
assert.doesNotMatch(sys, /Duplicate roast notes/);
assert.doesNotMatch(sys, /You are \*\*Jarvis\*\*/);
});
test('LLM compact skips a two-turn voice chat', async () => {
let called = false;
const hist = [
+6
View File
@@ -40,6 +40,12 @@ test('typed questions barge in from SPEAKING', () => {
test('spoken replies use harness text and strip HUD sidecars', () => {
assert.equal(spokenReply({ ok: true, text: 'Hello there.', reason: 'stop' }), 'Hello there.');
assert.equal(spokenReply({ text: 'Spoken. <jarvis_hud>{"title":"x","chips":[]}</jarvis_hud>' }), 'Spoken.');
assert.equal(
spokenReply({
text: 'I will look that up. <tool_call><function=web_search><parameter=query>weather</parameter></function></tool_call>',
}),
'I will look that up.',
);
assert.equal(spokenReply({}), '');
assert.equal(spokenReply('[object Object]'), '');
});
+13 -3
View File
@@ -160,9 +160,17 @@ test('public web search and fetch do not require confirmation', () => {
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.doesNotMatch(VOICE_SYSTEM_PROMPT, /\n## Acting\n/);
assert.match(voiceSystemPrompt('Ada', 'Be dry. Skip filler.'), /Your name is Ada/);
assert.match(voiceSystemPrompt('Ada', 'Be dry. Skip filler.'), /\n## Acting\nBe dry\. Skip filler\./);
assert.match(voiceSystemPrompt('Ada', 'Be dry. Skip filler.'), /override SOUL\.md/);
assert.doesNotMatch(voiceSystemPrompt('Ada', 'Be dry. Skip filler.'), /User personality notes/);
assert.doesNotMatch(voiceSystemPrompt('Tony', 'Jarvis, you are acting as Tony Hinchcliffe.'), /You are Tony, a local Ubuntu GNOME voice assistant/);
const roast = voiceSystemPrompt('Tony', 'Jarvis, you are acting as Tony Hinchcliffe, roast comedian.\nYou must never call yourself Jarvis.');
assert.match(roast, /Your name is Tony/);
assert.match(roast, /## Acting/);
assert.match(roast, /never call yourself Jarvis/);
assert.match(roast, /still speak as Tony/);
assert.equal(policy.needsPermission('write_file', 'ask'), true);
assert.equal(policy.isIdentityPath('USER.md'), true);
assert.equal(policy.isIdentityPath('PERSONA.md'), true);
@@ -207,6 +215,8 @@ test('QVAC utility tools are exposed only through the master adapter', () => {
});
test('model profiles select one master model without creating another runtime', () => {
assert.equal(profile('laptop-4gb-mm').model, 'qwen3.5-0.8b');
assert.equal(profile('laptop-4gb-mm').vision, true);
assert.equal(profile('laptop-8gb-mm').model, 'qwen3.5-2b');
assert.equal(profile('laptop-8gb-mm').vision, true);
assert.equal(profile('desktop-gpu').model, 'qwen3.5-9b');
+1
View File
@@ -36,6 +36,7 @@ test('saving one window preserves unrelated changes from another and removes con
assert.deepEqual(merged, { ttsSpeed: 1.5, unrelated: 'kept', ttsEnabled: true });
assert.equal(normalizeSettings(merged, SETTINGS_FIELDS).ttsSpeed, 1.5);
assert.equal(voiceSettings({ modelProfile: 'laptop-8gb-mm' }).modelProfile, 'laptop-8gb-mm');
assert.equal(voiceSettings({ modelProfile: 'laptop-4gb-mm' }).modelProfile, 'laptop-4gb-mm');
});
test('every speech preset uses a real registry asset and passes the installed SDK schema', () => {
+40
View File
@@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { profile } from '../daemon/model-profiles.js';
const require = createRequire(import.meta.url);
const catalog = require('../vendor/agent-harness/lib/catalog.js');
const toolParse = require('../vendor/agent-harness/lib/tool-parse.js');
test('Qwen3.5 0.8B and 2B use the qwen35 tool dialect and compact-tool reminder', () => {
assert.equal(profile('laptop-4gb-mm').model, 'qwen3.5-0.8b');
assert.equal(profile('laptop-8gb-mm').model, 'qwen3.5-2b');
for (const id of ['qwen3.5-0.8b', 'qwen3.5-2b']) {
assert.equal(catalog.toolDialectFor(id), 'qwen35');
assert.equal(catalog.findCatalogEntry(id).tools, true);
assert.equal(catalog.isCompactToolModel(id), true);
}
assert.match(toolParse.FORMAT_REMINDER, /<function=TOOL_NAME>/);
const tiny = catalog.filterToolsForModel(
[{ name: 'web_search' }, { name: 'qvac_capability' }, { name: 'cu_drag' }],
'qwen3.5-0.8b',
);
assert.deepEqual(tiny.map((t) => t.name), ['web_search']);
});
test('Qwen3.5 compact models recover tool calls nested in think tags', () => {
const tools = [{ name: 'web_search' }, { name: 'web_fetch' }];
const thinking = `<think>
Need a current answer.
<tool_call>
<function=web_search>
<parameter=query>Ubuntu 26.04 release</parameter>
</function>
</tool_call>
</think>`;
const recovered = toolParse.recover({ thinking, text: '', tools });
assert.equal(recovered.calls.length, 1);
assert.equal(recovered.calls[0].name, 'web_search');
assert.equal(recovered.calls[0].arguments.query, 'Ubuntu 26.04 release');
});
+1 -1
View File
@@ -10,7 +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.
4. Follow acting instructions from Settings when present. Those live in the system prompt, not as a second identity.
Do not invent a name. Skills: only the catalog is inlined. `read_file` the matching `SKILL.md` when a task fits.
+1 -1
View File
@@ -42,7 +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
- `PERSONA.md`: copy of Settings acting notes; the live copy lives in the system prompt
- `MEMORY.md` and `memory/YYYY-MM-DD.md`: what youve learned
Read them. Update them when something durable happens. That is how you persist.
+19 -2
View File
@@ -4,6 +4,7 @@
const engine = require('../lib/qvac.js');
const catalog = require('../lib/catalog.js');
const toolParse = require('../lib/tool-parse.js');
const sessions = require('./sessions.js');
const tools = require('./tools.js');
const sandbox = require('./sandbox.js');
@@ -232,7 +233,7 @@ function resolvePlanDecision(sessionId, decision) {
function buildToolDefs(session, payload, tracker) {
const hostWorkspace = session.hostWorkspace !== false;
return tools
const defs = tools
.defs({
planMode: planMode.isActive(tracker),
webFetch: payload && payload.webFetch,
@@ -240,6 +241,7 @@ function buildToolDefs(session, payload, tracker) {
builtinTools: session.builtinTools,
})
.concat(customTools.defs(session.id));
return catalog.filterToolsForModel(defs, session.model);
}
function refreshSystem(session, sys) {
@@ -394,7 +396,7 @@ async function runTurn(ctx) {
if (session.goal && goalMod.isActive(session.goal)) {
extraSys = [extraSys, goalMod.plannerAddendum(session.goal)].filter(Boolean).join('\n\n');
}
const sys = prompts.assemble({
const sysBase = prompts.assemble({
cwd: hostWorkspace ? cwd : session.workspace || cwd,
hostWorkspace,
extra: extraSys,
@@ -410,6 +412,9 @@ async function runTurn(ctx) {
}
: null,
});
const sys = catalog.isCompactToolModel(session.model)
? sysBase + '\n\n' + toolParse.FORMAT_REMINDER
: sysBase;
const sidecars = [];
if (hostWorkspace) {
if (!voice) {
@@ -737,6 +742,18 @@ async function runTurn(ctx) {
);
let calls = (result && result.toolCalls) || [];
if (!calls.length && result) {
const recovered = toolParse.recover({
text: result.text,
thinking: result.thinking,
tools: toolDefs,
existing: calls,
});
calls = recovered.calls;
if (recovered.text != null) result.text = recovered.text;
} else if (result && result.text) {
result.text = toolParse.stripToolMarkup(result.text);
}
if (budget.answerOnly) calls = [];
if ((result && result.text) || calls.length) {
if (result.text) lastText = result.text;
+11 -2
View File
@@ -52,12 +52,21 @@ function loadWorkspaceRules(fsRead, cwd) {
return loadWorkspaceFiles(fsRead, cwd, ['AGENTS.md', '.agent-harness/AGENTS.md']);
}
function voiceWorkspaceFiles(extra) {
const acting = /\n## Acting\n/.test(String(extra || ''));
return VOICE_WORKSPACE_FILES.filter((name) => {
if (name === 'PERSONA.md') return false;
if (acting && (name === 'SOUL.md' || name === 'IDENTITY.md')) return false;
return true;
});
}
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);
const files = fsRead ? loadWorkspaceFiles(fsRead, cwd, VOICE_WORKSPACE_FILES) : '';
const files = fsRead ? loadWorkspaceFiles(fsRead, cwd, voiceWorkspaceFiles(extra)) : '';
if (files) parts.push(files);
return parts.join('\n\n');
}
@@ -69,4 +78,4 @@ function assemble({ cwd, extra, fsRead, hostWorkspace, personality }) {
return parts.join('\n\n');
}
module.exports = { DEFAULT_SYSTEM, PAGE_SYSTEM, assemble, loadWorkspaceRules, VOICE_WORKSPACE_FILES };
module.exports = { DEFAULT_SYSTEM, PAGE_SYSTEM, assemble, loadWorkspaceRules, VOICE_WORKSPACE_FILES, voiceWorkspaceFiles };
+60
View File
@@ -9,6 +9,7 @@ const CATALOG = [
constant: 'QWEN3_5_0_8B_MULTIMODAL_Q4_K_M',
name: 'Qwen3.5 0.8B',
tools: true,
compactTools: true,
vision: true,
mmproj: 'MMPROJ_QWEN3_5_0_8B_MULTIMODAL_Q8_0',
minRamGb: 4,
@@ -20,6 +21,7 @@ const CATALOG = [
constant: 'QWEN3_5_2B_MULTIMODAL_Q4_K_M',
name: 'Qwen3.5 2B',
tools: true,
compactTools: true,
vision: true,
mmproj: 'MMPROJ_QWEN3_5_2B_MULTIMODAL_Q8_0',
minRamGb: 6,
@@ -149,6 +151,7 @@ const CATALOG = [
constant: 'QWEN3_1_7B_INST_Q4',
name: 'Qwen3 1.7B',
tools: true,
compactTools: true,
vision: false,
minRamGb: 8,
approxDownloadGb: 1.2,
@@ -275,6 +278,60 @@ function toolDialectFor(idOrConstant) {
return 'hermes';
}
function isCompactToolModel(idOrConstant) {
const e = findCatalogEntry(idOrConstant);
if (e && e.compactTools) return true;
const id = String((e && e.id) || idOrConstant || '').toLowerCase();
return /qwen3\.5-0\.8b|qwen3\.5-2b|qwen3-1\.7b/.test(id);
}
const COMPACT_TOOL_ALLOW = [
'read_file',
'write_file',
'search_replace',
'list_dir',
'grep',
'run_terminal_cmd',
'web_search',
'google_search',
'fetch_page',
'web_fetch',
'wiki_search',
'hn_search',
'code_search',
'jarvis_status',
'cu_status',
'cu_observe',
'cu_find',
'cu_click',
'cu_type',
'cu_key',
'fs_search',
'fs_read',
'fs_write',
'app_list',
'memory_recall',
'memory_remember',
'memory_search',
'memory_get',
'memory_write',
'capability_status',
'qvac_runtime_state',
'qvac_system_resources',
];
function compactToolAllowlist() {
return new Set(COMPACT_TOOL_ALLOW);
}
function filterToolsForModel(defs, idOrConstant) {
const list = Array.isArray(defs) ? defs : [];
if (!isCompactToolModel(idOrConstant)) return list;
const allow = compactToolAllowlist();
const filtered = list.filter((t) => t && allow.has(t.name));
return filtered.length ? filtered : list;
}
module.exports = {
CATALOG,
FALLBACK_LLM_IDS,
@@ -286,5 +343,8 @@ module.exports = {
mmprojConstant,
mmprojCandidates,
toolDialectFor,
isCompactToolModel,
compactToolAllowlist,
filterToolsForModel,
catalogLabel,
};
+24 -3
View File
@@ -10,6 +10,7 @@ const device = require('./device.js');
const events = require('./events.js');
const paths = require('./paths.js');
const completeWatch = require('./complete-watch.js');
const toolParse = require('./tool-parse.js');
let sdk = null;
let initError = null;
@@ -154,11 +155,22 @@ function toTools(tools) {
return {
type: 'function',
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters,
description: t.function.description || t.function.name,
parameters: t.function.parameters && t.function.parameters.type === 'object'
? t.function.parameters
: { type: 'object', properties: {} },
};
}
return t;
if (!t || !t.name) return t;
const parameters = t.parameters && t.parameters.type === 'object'
? t.parameters
: { type: 'object', properties: (t.parameters && t.parameters.properties) || {} };
return {
type: 'function',
name: t.name,
description: t.description || t.name,
parameters,
};
});
}
@@ -455,6 +467,15 @@ async function complete(opts, onEvent) {
if (run.stats) stats = await run.stats;
} catch (_) {}
}
if (tools && tools.length) {
const recovered = toolParse.recover({ text, thinking, tools, existing: toolCalls });
if (!toolCalls.length && recovered.calls.length) {
for (const call of recovered.calls) toolCalls.push(call);
}
text = recovered.text;
} else {
text = toolParse.stripToolMarkup(text);
}
return {
text,
thinking,
+174
View File
@@ -0,0 +1,174 @@
/**
* Recover Qwen3.5 / Hermes tool calls that QVAC's stream framer misses.
*
* Compact Qwen3.5 models often emit <tool_call> inside <think></think>.
* The SDK thinking framer swallows that XML, so the agent never runs a tool.
* No Bare imports unit-testable on Node.
*/
const FORMAT_REMINDER =
'When you need a tool, close thinking first, then emit this XML (not JSON, not a spoken plan):\n' +
'<tool_call>\n<function=TOOL_NAME>\n<parameter=ARG>value</parameter>\n</function>\n</tool_call>';
const ALIASES = {
search: 'web_search',
websearch: 'web_search',
google: 'google_search',
googlesearch: 'google_search',
fetch: 'web_fetch',
webfetch: 'web_fetch',
fetchpage: 'fetch_page',
open_url: 'fetch_page',
openurl: 'fetch_page',
shell: 'run_terminal_cmd',
bash: 'run_terminal_cmd',
terminal: 'run_terminal_cmd',
cmd: 'run_terminal_cmd',
read: 'read_file',
cat: 'read_file',
write: 'write_file',
ls: 'list_dir',
status: 'jarvis_status',
};
function knownNames(tools) {
const names = new Set();
for (const t of tools || []) {
if (t && t.name) names.add(String(t.name));
if (t && t.function && t.function.name) names.add(String(t.function.name));
}
return names;
}
function remapName(name, names) {
const raw = String(name || '').trim();
if (!raw) return raw;
if (names.has(raw)) return raw;
const folded = raw.toLowerCase().replace(/[\s-]+/g, '_');
if (names.has(folded)) return folded;
const alias = ALIASES[folded];
if (alias && (names.size === 0 || names.has(alias))) return alias;
return raw;
}
function parseJsonArgs(value) {
if (value == null) return {};
if (typeof value === 'object' && !Array.isArray(value)) return value;
if (typeof value !== 'string') return { value };
const trimmed = value.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
return { value: parsed };
} catch (_) {
return { value: trimmed };
}
}
function parseQwenXmlInner(inner) {
const calls = [];
const fnRe = /<function\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/function>/gi;
let fn;
while ((fn = fnRe.exec(inner)) !== null) {
const args = {};
const paramRe = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/gi;
let pm;
while ((pm = paramRe.exec(fn[2])) !== null) args[pm[1].trim()] = String(pm[2]).trim();
if (!Object.keys(args).length) {
const lineRe = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*[:=]\s*(.+?)\s*$/gm;
let line;
while ((line = lineRe.exec(fn[2])) !== null) args[line[1]] = line[2].trim();
}
calls.push({ name: fn[1].trim(), arguments: args });
}
if (calls.length) return calls;
const openFn = /<function\s*=\s*([^>\s]+)\s*>([\s\S]*)$/i.exec(inner);
if (openFn) {
const args = {};
const paramRe = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)(?:<\/parameter>|$)/gi;
let pm;
while ((pm = paramRe.exec(openFn[2])) !== null) args[pm[1].trim()] = String(pm[2]).replace(/<\/parameter>\s*$/i, '').trim();
return [{ name: openFn[1].trim(), arguments: args }];
}
return calls;
}
function parseHermesInner(inner) {
const trimmed = String(inner || '').trim();
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return [];
try {
const parsed = JSON.parse(trimmed);
const list = Array.isArray(parsed) ? parsed : [parsed];
return list
.map((obj) => {
if (!obj || typeof obj !== 'object') return null;
const name = obj.name || (obj.function && obj.function.name);
if (!name) return null;
const args = obj.arguments != null ? obj.arguments : obj.parameters != null ? obj.parameters : obj.args != null ? obj.args : (obj.function && obj.function.arguments);
return { name: String(name), arguments: parseJsonArgs(args) };
})
.filter(Boolean);
} catch (_) {
return [];
}
}
function extractFrames(text) {
const src = String(text || '');
const frames = [];
const re = /<tool_call>([\s\S]*?)<\/tool_call>/gi;
let m;
while ((m = re.exec(src)) !== null) frames.push(m[1]);
if (!frames.length) {
const open = src.search(/<tool_call>/i);
if (open >= 0) frames.push(src.slice(open + '<tool_call>'.length));
}
if (!frames.length && /<function\s*=/i.test(src)) frames.push(src);
return frames;
}
function extractCalls(text, tools) {
const names = knownNames(tools);
const out = [];
const seen = new Set();
for (const frame of extractFrames(text)) {
let parsed = parseQwenXmlInner(frame);
if (!parsed.length) parsed = parseHermesInner(frame);
for (const call of parsed) {
const name = remapName(call.name, names);
const args = call.arguments && typeof call.arguments === 'object' ? call.arguments : {};
const key = name + ':' + JSON.stringify(args);
if (seen.has(key)) continue;
seen.add(key);
out.push({ name, arguments: args });
}
}
return out;
}
function stripToolMarkup(text) {
return String(text || '')
.replace(/<tool_call>[\s\S]*?<\/tool_call>/gi, '')
.replace(/<function\s*=[^>]*>[\s\S]*?<\/function>/gi, '')
.replace(/<tool_call>[\s\S]*$/gi, '')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function recover({ text, thinking, tools, existing } = {}) {
const spoken = stripToolMarkup(text);
const have = Array.isArray(existing) && existing.length;
const calls = have ? existing.slice() : extractCalls([thinking, text].filter(Boolean).join('\n'), tools);
return { calls, text: spoken };
}
module.exports = {
FORMAT_REMINDER,
ALIASES,
extractCalls,
stripToolMarkup,
recover,
remapName,
};
+61
View File
@@ -31,6 +31,22 @@ function testCatalog() {
assert.strictEqual(catalog.findCatalogEntry('qwen3-8b').vision, false);
assert.strictEqual(catalog.toolDialectFor('gemma4-4b'), 'gemma4');
assert.strictEqual(catalog.toolDialectFor('qwen3-8b'), 'hermes');
assert.strictEqual(catalog.toolDialectFor('qwen3.5-2b'), 'qwen35');
assert.strictEqual(catalog.toolDialectFor('qwen3.5-0.8b'), 'qwen35');
assert.strictEqual(catalog.toolDialectFor('QWEN3_5_2B_MULTIMODAL_Q4_K_M'), 'qwen35');
assert.strictEqual(catalog.findCatalogEntry('qwen3.5-2b').tools, true);
assert.strictEqual(catalog.findCatalogEntry('qwen3.5-2b').compactTools, true);
assert.strictEqual(catalog.findCatalogEntry('qwen3.5-0.8b').tools, true);
assert.strictEqual(catalog.findCatalogEntry('qwen3.5-0.8b').compactTools, true);
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-2b'), true);
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-0.8b'), true);
assert.strictEqual(catalog.isCompactToolModel('qwen3.5-4b'), false);
const tiny = catalog.filterToolsForModel(
[{ name: 'web_search' }, { name: 'qvac_capability' }, { name: 'cu_drag' }],
'qwen3.5-0.8b',
);
assert.deepStrictEqual(tiny.map((t) => t.name), ['web_search']);
assert.strictEqual(catalog.filterToolsForModel([{ name: 'cu_drag' }], 'qwen3.5-4b')[0].name, 'cu_drag');
assert.ok(/~5 GB/.test(catalog.catalogLabel(catalog.findCatalogEntry('qwen3-8b'))));
assert.ok(catalog.FALLBACK_LLM_IDS.indexOf('gemma4-4b') >= 0);
const listed = catalog.listCatalog().find((m) => m.id === 'gemma4-2b');
@@ -250,7 +266,52 @@ function testDevicePrefersGpu() {
assert.strictEqual(device.backendLabel(norm).backend, 'vulkan');
}
function testToolParse() {
const parse = require('../lib/tool-parse.js');
const tools = [
{ name: 'web_search', parameters: { type: 'object', properties: { query: { type: 'string' } } } },
{ name: 'run_terminal_cmd', parameters: { type: 'object', properties: { command: { type: 'string' } } } },
];
const xml = [
'<think>',
'I should look that up.',
'<tool_call>',
'<function=web_search>',
'<parameter=query>weather in Paris</parameter>',
'</function>',
'</tool_call>',
'</think>',
].join('\n');
const nested = parse.extractCalls(xml, tools);
assert.strictEqual(nested.length, 1);
assert.strictEqual(nested[0].name, 'web_search');
assert.strictEqual(nested[0].arguments.query, 'weather in Paris');
const hermes = parse.extractCalls(
'<think><tool_call>{"name":"web_search","arguments":{"query":"news"}}</tool_call></think>',
tools
);
assert.strictEqual(hermes[0].name, 'web_search');
assert.strictEqual(hermes[0].arguments.query, 'news');
const aliased = parse.extractCalls(
'<tool_call><function=search><parameter=query>headlines</parameter></function></tool_call>',
tools
);
assert.strictEqual(aliased[0].name, 'web_search');
const recovered = parse.recover({
thinking: xml,
text: 'I will search now.\n<tool_call><function=web_search><parameter=query>x</parameter></function></tool_call>',
tools,
});
assert.strictEqual(recovered.calls[0].name, 'web_search');
assert.strictEqual(recovered.text, 'I will search now.');
assert.ok(parse.FORMAT_REMINDER.indexOf('<function=TOOL_NAME>') >= 0);
}
testCatalog();
testToolParse();
testCompaction();
testSearchReplace();
testToolSet();