feat!: rebuild EveFlow as a JARVIS-style voice HUD for Hermes Agent (v2.0.0)

Complete rewrite of the application:

- Toolchain: Electron 44, Vite 8, React 19, TypeScript 5.9, Zustand, Vitest;
  main process rewritten in TypeScript (electron/), typed IPC contract (shared/),
  sandboxed renderer with webSecurity on and an HTTP/SSE proxy in the main process.
- Voice: AudioWorklet microphone capture at 16 kHz with adaptive energy VAD and
  auto-stop, WAV encoder, OpenAI-compatible STT, TTS queue with sentence-level
  streaming, prefetch and Web Audio playback feeding an analyser; hands-free mode,
  global push-to-talk hotkey, system-voice fallback.
- Hermes: full API client (capabilities, health, models, skills, toolsets,
  sessions, jobs) with automatic transport selection: runs API (SSE lifecycle,
  approvals, steer, stop) > sessions stream > chat completions with local tools;
  tolerant event normalisation; webhook receiver hardened (secret, size limit).
- UI: JARVIS arc-reactor core on Canvas 2D reacting to the real audio signal,
  holographic HUD layout (transcript, core, Hermes ops panel with tools/crons/
  skills/sessions/link tabs, telemetry), settings drawer, approval modals,
  compact floating widget, four themes, tray icon and global shortcuts.
- Removed the 3D Eve robot, three.js and legacy assets; migrated 1.x settings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Wn5VX9HNbJ7N54hR24u9Y
This commit is contained in:
Claude committed 2026-09-03 13:40:59 +00:00
1 parent 5217eee53a
commit 127a2083a1
239 files changed
+11351 -86495

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { normalizeCompletionChunk, normalizeLifecycleEvent } from '../src/services/hermes/events';
import { hermesBaseUrl, resolveTransport } from '../src/services/hermes/client';
import type { HermesConfig } from '../src/services/hermes/types';
describe('normalizeLifecycleEvent', () => {
it('maps runs API events', () => {
expect(normalizeLifecycleEvent('message.delta', { delta: 'Hi' })).toEqual([{ kind: 'delta', text: 'Hi' }]);
const [start] = normalizeLifecycleEvent('tool.started', { tool: 'terminal', preview: 'ls', run_id: 'r1' });
expect(start).toMatchObject({ kind: 'tool.start', name: 'terminal', args: 'ls' });
const [end] = normalizeLifecycleEvent('tool.completed', { tool: 'terminal', error: 'boom' });
expect(end).toMatchObject({ kind: 'tool.end', ok: false });
const [approval] = normalizeLifecycleEvent('approval.request', { command: 'rm -rf x', choices: ['once', 'deny'] });
expect(approval).toMatchObject({ kind: 'approval.request', description: 'rm -rf x', options: ['once', 'deny'] });
const done = normalizeLifecycleEvent('run.completed', { output: 'ok', usage: { total_tokens: 3 }, session_id: 's1' });
expect(done[0]).toMatchObject({ kind: 'completed', text: 'ok', usage: { total_tokens: 3 } });
expect(done[1]).toEqual({ kind: 'session', sessionId: 's1' });
expect(normalizeLifecycleEvent('run.failed', { error: 'bad' })[0]).toEqual({ kind: 'error', message: 'bad' });
});
it('maps sessions stream and chat-completions tool progress', () => {
expect(normalizeLifecycleEvent('assistant.delta', { delta: 'a' })[0]).toEqual({ kind: 'delta', text: 'a' });
const [progress] = normalizeLifecycleEvent('hermes.tool.progress', { tool_name: 'web_search', status: 'started' });
expect(progress).toMatchObject({ kind: 'tool.progress', name: 'web_search' });
expect(normalizeLifecycleEvent('gateway.ready', {})).toEqual([]);
expect(normalizeLifecycleEvent('weird.event', { x: 1 })[0]).toMatchObject({ kind: 'raw' });
});
});
describe('normalizeCompletionChunk', () => {
it('accumulates streamed tool calls', () => {
const calls = new Map();
normalizeCompletionChunk({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'set_', arguments: '{"a' } }] } }] }, calls);
const { events, finishReason } = normalizeCompletionChunk(
{ choices: [{ delta: { content: 'x', tool_calls: [{ index: 0, function: { name: 'hud', arguments: '":1}' } }] }, finish_reason: 'tool_calls' }] },
calls
);
expect(events).toEqual([{ kind: 'delta', text: 'x' }]);
expect(finishReason).toBe('tool_calls');
expect(calls.get(0)).toEqual({ id: 'c1', name: 'set_hud', arguments: '{"a":1}' });
});
});
describe('client helpers', () => {
const cfg = { transport: 'auto' } as HermesConfig;
it('normalises base urls', () => {
expect(hermesBaseUrl('http://h:8642/v1/')).toBe('http://h:8642');
expect(hermesBaseUrl('http://h:8642/v1/chat/completions')).toBe('http://h:8642');
expect(hermesBaseUrl('http://h:8642')).toBe('http://h:8642');
});
it('resolves transports from capabilities', () => {
expect(resolveTransport(cfg, null)).toBe('completions');
expect(resolveTransport(cfg, { features: { run_submission: true, run_events_sse: true } })).toBe('runs');
expect(resolveTransport(cfg, { endpoints: { session_chat: true } })).toBe('sessions');
expect(resolveTransport({ transport: 'completions' } as HermesConfig, { features: { run_submission: true, run_events_sse: true } })).toBe('completions');
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { normalizeHermesPush } from '../shared/hermesPush';
describe('normalizeHermesPush', () => {
it('handles run.completed with input/output', () => {
const events = normalizeHermesPush({ event: 'run.completed', input: 'hi', output: 'hello', platform: 'telegram' });
expect(events.map((e) => [e.role, e.text, e.source])).toEqual([['user', 'hi', 'telegram'], ['assistant', 'hello', 'telegram']]);
});
it('handles cron job payloads and images', () => {
const [e] = normalizeHermesPush({ event: 'job.completed', job: { name: 'Daily', last_status: 'ok' }, output: 'report', images: ['https://a/b.png'] });
expect(e.type).toBe('job');
expect(e.jobName).toBe('Daily');
expect(e.images).toEqual(['https://a/b.png']);
});
it('handles direct and legacy shapes', () => {
expect(normalizeHermesPush({ role: 'user', text: 'x' })[0].role).toBe('user');
expect(normalizeHermesPush({ type: 'assistant_message', text: 'y' })[0].role).toBe('assistant');
expect(normalizeHermesPush({ foo: 1 })[0].type).toBe('raw');
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { SseParser } from '../src/lib/sse';
describe('SseParser', () => {
it('parses events split across chunks and multi-line data', () => {
const got: Array<{ event: string; data: string }> = [];
const p = new SseParser((m) => got.push({ event: m.event, data: m.data }));
p.feed('event: hermes.tool.progress\ndata: {"a":');
p.feed('1}\n\ndata: line1\r\ndata: line2\r\n\r\n: comment\n');
p.feed('data: [DONE]');
p.end();
expect(got).toEqual([
{ event: 'hermes.tool.progress', data: '{"a":1}' },
{ event: 'message', data: 'line1\nline2' },
{ event: 'message', data: '[DONE]' }
]);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { chunkForSpeech, cleanForSpeech, extractSentences, preprocessMedia } from '../src/lib/text';
describe('text', () => {
it('cleans markdown for speech', () => {
const out = cleanForSpeech('**Bonjour** ! Voir [le site](https://x.io) et `npm` 😀\n\n```js\nconsole.log(1)\n```\n- item un\n- item deux');
expect(out).not.toMatch(/\*|https|```|😀/);
expect(out).toContain('Bonjour');
expect(out).toContain('item un');
});
it('extracts complete sentences from a stream buffer', () => {
const { sentences, rest } = extractSentences('Première phrase complète. Deuxième phrase aussi ! Troisième en cours');
expect(sentences).toEqual(['Première phrase complète.', 'Deuxième phrase aussi !']);
expect(rest).toBe(' Troisième en cours');
});
it('does not split inside an open code fence', () => {
expect(extractSentences('Voici. ```js\nfoo(). bar\n').sentences).toEqual([]);
});
it('chunks long text', () => {
const chunks = chunkForSpeech('mot '.repeat(200), 100);
expect(chunks.every((c) => c.length <= 100)).toBe(true);
expect(preprocessMedia('MEDIA:/tmp/a.png')).toContain('![image](/tmp/a.png)');
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { EnergyVad } from '../src/services/voice/vad';
const feed = (vad: EnergyVad, rms: number, ms: number, step = 64): string[] => {
const out: string[] = [];
for (let t = 0; t < ms; t += step) out.push(vad.feed(rms, step));
return out;
};
describe('EnergyVad', () => {
it('detects start and end of speech after silence', () => {
const vad = new EnergyVad({ silenceMs: 500, minSpeechMs: 200 });
expect(new Set(feed(vad, 0.003, 600))).toEqual(new Set(['idle']));
expect(feed(vad, 0.2, 800)).toContain('speech-start');
expect(feed(vad, 0.003, 700)).toContain('speech-end');
expect(vad.isSpeaking).toBe(false);
});
it('reports too-short bursts and no-speech timeouts', () => {
const vad = new EnergyVad({ silenceMs: 300, minSpeechMs: 400, noSpeechTimeoutMs: 20000 });
feed(vad, 0.003, 500);
feed(vad, 0.3, 150);
expect(feed(vad, 0.003, 400)).toContain('too-short');
const quiet = new EnergyVad({ noSpeechTimeoutMs: 1000 });
expect(feed(quiet, 0.002, 1200)).toContain('no-speech');
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { buildWav16k, encodeWav, resample } from '../src/services/voice/wav';
describe('wav', () => {
it('encodes a valid RIFF header at 16 kHz', () => {
const samples = new Float32Array(1600).fill(0.5);
const bytes = encodeWav(samples, 16000);
const view = new DataView(bytes.buffer);
expect(String.fromCharCode(...bytes.subarray(0, 4))).toBe('RIFF');
expect(view.getUint32(24, true)).toBe(16000);
expect(view.getUint16(34, true)).toBe(16);
expect(bytes.length).toBe(44 + 1600 * 2);
});
it('resamples 48k to 16k and builds a wav', () => {
const chunk = new Float32Array(4800).map((_, i) => Math.sin(i / 10) * 0.1);
const wav = buildWav16k([chunk, chunk], 48000);
expect(wav.sampleRate).toBe(16000);
expect(Math.round(wav.durationSec * 100)).toBe(20);
expect(resample(new Float32Array([0, 1]), 1, 2).length).toBe(4);
});
});