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

+492
View File
@@ -0,0 +1,492 @@
/**
* Full Hermes Agent API client: discovery, runs (SSE lifecycle + approvals + steering),
* sessions (server-side memory), OpenAI-compatible chat completions (with EveFlow tools)
* and the cron jobs REST API.
*/
import { Log } from '../../lib/log';
import { SseParser, tryParseJson } from '../../lib/sse';
import { httpFetch, httpStream, HttpError, type StreamHandle } from '../../lib/transport';
import { normalizeCompletionChunk, normalizeLifecycleEvent, type CompletionToolCallAccumulator } from './events';
import type {
HermesCapabilities,
HermesChatMessage,
HermesConfig,
HermesHealth,
HermesJob,
HermesJobDraft,
HermesModel,
HermesRunInfo,
HermesSession,
HermesSessionMessage,
HermesSkill,
HermesStreamEvent,
HermesToolset,
HermesTransport,
SendHandle,
SendOptions
} from './types';
type Rec = Record<string, unknown>;
const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.isArray(v);
export type ResolvedTransport = Exclude<HermesTransport, 'auto'>;
export function hermesBaseUrl(url: string): string {
let base = url.trim().replace(/\/+$/, '');
base = base.replace(/\/chat\/completions$/, '');
base = base.replace(/\/v1$/, '');
return base;
}
function extractArray<T>(payload: unknown, keys: string[]): T[] {
if (Array.isArray(payload)) return payload as T[];
if (isRec(payload)) {
for (const key of keys) {
const value = payload[key];
if (Array.isArray(value)) return value as T[];
}
if (isRec(payload.data)) return extractArray<T>(payload.data, keys);
}
return [];
}
function errorMessage(status: number, body: string): string {
const parsed = tryParseJson<Rec>(body);
if (parsed) {
const err = parsed.error;
if (isRec(err) && typeof err.message === 'string') return err.message;
if (typeof err === 'string') return err;
for (const key of ['message', 'detail', 'error_description']) {
if (typeof parsed[key] === 'string') return parsed[key] as string;
}
}
const text = body.trim().slice(0, 240);
if (status === 401 || status === 403) return `Authentification refusée (HTTP ${status}). Vérifiez la clé API_SERVER_KEY.`;
if (status === 404) return `Endpoint introuvable (HTTP 404)${text ? ` : ${text}` : ''}`;
if (status === 429) return 'Hermes est saturé (HTTP 429, max_concurrent_runs atteint).';
return `HTTP ${status}${text ? ` : ${text}` : ''}`;
}
export function resolveTransport(config: HermesConfig, caps: HermesCapabilities | null): ResolvedTransport {
if (config.transport !== 'auto') return config.transport;
if (!caps) return 'completions';
const features = caps.features ?? {};
const endpoints = caps.endpoints ?? {};
if (features.run_submission && features.run_events_sse) return 'runs';
if (endpoints.session_chat) return 'sessions';
return 'completions';
}
export class HermesClient {
constructor(private readonly config: HermesConfig) {}
get base(): string {
return hermesBaseUrl(this.config.url);
}
private headers(extra: Record<string, string> = {}, json = true): Record<string, string> {
const h: Record<string, string> = { ...extra };
if (json) h['Content-Type'] = 'application/json';
h.Accept = h.Accept ?? 'application/json';
if (this.config.apiKey.trim()) h.Authorization = `Bearer ${this.config.apiKey.trim()}`;
if (this.config.sessionKey.trim()) h['X-Hermes-Session-Key'] = this.config.sessionKey.trim().slice(0, 256);
return h;
}
private async request<T>(path: string, init: { method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'; body?: unknown; timeoutMs?: number } = {}): Promise<T> {
if (!this.config.url.trim()) throw new Error("URL Hermes non configurée.");
const url = `${this.base}${path}`;
const res = await httpFetch({
url,
method: init.method ?? 'GET',
headers: this.headers(),
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
timeoutMs: init.timeoutMs ?? 20_000
});
if (!res.ok) throw new HttpError(res.status, errorMessage(res.status, res.text ?? ''), res.text);
const text = res.text ?? '';
if (!text.trim()) return null as T;
const parsed = tryParseJson<T>(text);
if (parsed === null) throw new Error(`Réponse Hermes illisible depuis ${path}`);
return parsed;
}
// ── Discovery ─────────────────────────────────────────────────────────────
capabilities(): Promise<HermesCapabilities> {
return this.request<HermesCapabilities>('/v1/capabilities');
}
async health(): Promise<HermesHealth> {
try {
return await this.request<HermesHealth>('/health/detailed', { timeoutMs: 8000 });
} catch (err) {
if (err instanceof HttpError && (err.status === 401 || err.status === 403)) throw err;
const basic = await this.request<HermesHealth | Rec>('/health', { timeoutMs: 8000 });
return { status: 'ok', ...(isRec(basic) ? basic : {}) } as HermesHealth;
}
}
async models(): Promise<HermesModel[]> {
const payload = await this.request<unknown>('/v1/models');
return extractArray<HermesModel>(payload, ['data', 'models']);
}
async skills(): Promise<HermesSkill[]> {
const payload = await this.request<unknown>('/v1/skills');
return extractArray<HermesSkill>(payload, ['skills', 'data', 'items']);
}
async toolsets(): Promise<HermesToolset[]> {
const payload = await this.request<unknown>('/v1/toolsets');
return extractArray<HermesToolset>(payload, ['toolsets', 'data', 'items']);
}
// ── Sessions ──────────────────────────────────────────────────────────────
async listSessions(limit = 40): Promise<HermesSession[]> {
const payload = await this.request<unknown>(`/api/sessions?limit=${limit}`);
return extractArray<HermesSession>(payload, ['sessions', 'items', 'data', 'results']);
}
async createSession(title?: string): Promise<HermesSession> {
const payload = await this.request<unknown>('/api/sessions', { method: 'POST', body: title ? { title } : {} });
if (isRec(payload) && isRec(payload.session)) return payload.session as HermesSession;
return payload as HermesSession;
}
getSession(id: string): Promise<HermesSession> {
return this.request<HermesSession>(`/api/sessions/${encodeURIComponent(id)}`);
}
async sessionMessages(id: string): Promise<HermesSessionMessage[]> {
const payload = await this.request<unknown>(`/api/sessions/${encodeURIComponent(id)}/messages`);
return extractArray<HermesSessionMessage>(payload, ['messages', 'items', 'data']);
}
updateSession(id: string, patch: { title?: string; end_reason?: string }): Promise<HermesSession> {
return this.request<HermesSession>(`/api/sessions/${encodeURIComponent(id)}`, { method: 'PATCH', body: patch });
}
deleteSession(id: string): Promise<void> {
return this.request<void>(`/api/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
async forkSession(id: string): Promise<HermesSession> {
const payload = await this.request<unknown>(`/api/sessions/${encodeURIComponent(id)}/fork`, { method: 'POST', body: {} });
if (isRec(payload) && isRec(payload.session)) return payload.session as HermesSession;
return payload as HermesSession;
}
// ── Jobs (cron) ───────────────────────────────────────────────────────────
async listJobs(): Promise<HermesJob[]> {
const payload = await this.request<unknown>('/api/jobs');
return extractArray<HermesJob>(payload, ['jobs', 'items', 'data']).map(normalizeJob);
}
private unwrapJob(payload: unknown): HermesJob {
if (isRec(payload) && isRec(payload.job)) return normalizeJob(payload.job as HermesJob);
return normalizeJob(payload as HermesJob);
}
async createJob(draft: HermesJobDraft): Promise<HermesJob> {
const body: Rec = { name: draft.name, schedule: draft.schedule, prompt: draft.prompt };
if (draft.deliver) body.deliver = draft.deliver;
if (draft.skills?.length) body.skills = draft.skills;
return this.unwrapJob(await this.request<unknown>('/api/jobs', { method: 'POST', body }));
}
async updateJob(id: string, patch: Partial<HermesJobDraft>): Promise<HermesJob> {
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}`, { method: 'PATCH', body: patch }));
}
deleteJob(id: string): Promise<void> {
return this.request<void>(`/api/jobs/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
async pauseJob(id: string): Promise<HermesJob> {
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}/pause`, { method: 'POST', body: {} }));
}
async resumeJob(id: string): Promise<HermesJob> {
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}/resume`, { method: 'POST', body: {} }));
}
async runJob(id: string): Promise<HermesJob> {
return this.unwrapJob(await this.request<unknown>(`/api/jobs/${encodeURIComponent(id)}/run`, { method: 'POST', body: {}, timeoutMs: 30_000 }));
}
// ── Runs ──────────────────────────────────────────────────────────────────
async startRun(body: { input: string; session_id?: string; instructions?: string; model?: string }): Promise<{ run_id: string; status: string }> {
const payload: Rec = { input: body.input };
if (body.session_id) payload.session_id = body.session_id;
if (body.instructions) payload.instructions = body.instructions;
if (body.model) payload.model = body.model;
return this.request<{ run_id: string; status: string }>('/v1/runs', { method: 'POST', body: payload, timeoutMs: 30_000 });
}
getRun(runId: string): Promise<HermesRunInfo> {
return this.request<HermesRunInfo>(`/v1/runs/${encodeURIComponent(runId)}`);
}
stopRun(runId: string): Promise<unknown> {
return this.request<unknown>(`/v1/runs/${encodeURIComponent(runId)}/stop`, { method: 'POST', body: {} });
}
steerRun(runId: string, text: string): Promise<unknown> {
return this.request<unknown>(`/v1/runs/${encodeURIComponent(runId)}/steer`, { method: 'POST', body: { input: text } });
}
approveRun(runId: string, choice: 'once' | 'session' | 'always' | 'deny', requestId?: string): Promise<unknown> {
const body: Rec = { choice };
if (requestId) body.request_id = requestId;
return this.request<unknown>(`/v1/runs/${encodeURIComponent(runId)}/approval`, { method: 'POST', body });
}
/** Subscribe to the lifecycle stream of a run. */
async streamRunEvents(runId: string, onEvent: (event: HermesStreamEvent) => void): Promise<StreamHandle> {
return this.openSse(`/v1/runs/${encodeURIComponent(runId)}/events`, { method: 'GET' }, onEvent);
}
private async openSse(
path: string,
init: { method: 'GET' | 'POST'; body?: unknown },
onEvent: (event: HermesStreamEvent) => void
): Promise<StreamHandle> {
const parser = new SseParser((message) => {
const data = tryParseJson<unknown>(message.data) ?? message.data;
const eventName = message.event !== 'message' ? message.event : isRec(data) && typeof data.event === 'string' ? data.event : 'message';
for (const event of normalizeLifecycleEvent(eventName, data)) onEvent(event);
});
const errorChunks: string[] = [];
const handle = await httpStream(
{
url: `${this.base}${path}`,
method: init.method,
headers: this.headers({ Accept: 'text/event-stream' }, init.body !== undefined),
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
timeoutMs: 15 * 60_000
},
{ onChunk: (text) => (handleReady ? parser.feed(text) : errorChunks.push(text)) }
);
let handleReady = handle.start.ok;
if (!handle.start.ok) {
await handle.done.catch(() => undefined);
throw new HttpError(handle.start.status, errorMessage(handle.start.status, errorChunks.join('')));
}
handleReady = true;
const done = handle.done.then(() => parser.end());
return { ...handle, done };
}
// ── Unified send ──────────────────────────────────────────────────────────
send(options: SendOptions, transport: ResolvedTransport): SendHandle {
const effective: ResolvedTransport = options.images?.length && transport === 'runs' ? 'completions' : transport;
let abort: () => void = () => undefined;
const result =
effective === 'runs'
? this.sendViaRuns(options, (fn) => (abort = fn))
: effective === 'sessions'
? this.sendViaSessions(options, (fn) => (abort = fn))
: this.sendViaCompletions(options, (fn) => (abort = fn));
return { result, abort: () => abort(), transport: effective };
}
private async sendViaRuns(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> {
const { onEvent } = options;
const run = await this.startRun({
input: options.text,
session_id: options.sessionId || undefined,
instructions: this.config.instructions || undefined,
model: this.config.model || undefined
});
const runId = run.run_id;
onEvent({ kind: 'run.started', runId });
Log.info('hermes', `run ${runId} started`);
let stopped = false;
let finalText = '';
let streamedText = '';
let completed = false;
let failure: string | null = null;
const handle = await this.streamRunEvents(runId, (event) => {
if (event.kind === 'delta') streamedText += event.text;
if (event.kind === 'completed' && event.status !== 'message') {
completed = true;
finalText = event.text ?? '';
}
if (event.kind === 'error') failure = event.message;
onEvent(event);
});
setAbort(() => {
stopped = true;
handle.abort();
this.stopRun(runId).catch(() => undefined);
});
await handle.done;
if (stopped) return streamedText;
if (failure) throw new Error(failure);
if (!completed) {
// The event buffer may have been consumed by a reconnect; fall back to polling the run status.
for (let i = 0; i < 120 && !stopped; i++) {
const info = await this.getRun(runId);
if (['completed', 'failed', 'cancelled', 'canceled', 'stopped'].includes(info.status)) {
if (info.status !== 'completed') throw new Error(String(info.error ?? `run ${info.status}`));
finalText = info.output ?? '';
onEvent({ kind: 'completed', text: finalText, status: info.status, usage: info.usage });
if (info.session_id) onEvent({ kind: 'session', sessionId: info.session_id });
break;
}
await new Promise((r) => setTimeout(r, 1000));
}
}
return finalText || streamedText;
}
private async sendViaSessions(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> {
const { onEvent } = options;
let sessionId = options.sessionId;
if (!sessionId || !sessionId.startsWith('hs:')) {
const session = await this.createSession('EveFlow');
sessionId = `hs:${session.id}`;
onEvent({ kind: 'session', sessionId });
}
const realId = sessionId.slice(3);
const body: Rec = { input: options.text };
if (this.config.instructions) body.instructions = this.config.instructions;
if (this.config.model) body.model = this.config.model;
let streamed = '';
let finalText = '';
let failure: string | null = null;
const handle = await this.openSse(`/api/sessions/${encodeURIComponent(realId)}/chat/stream`, { method: 'POST', body }, (event) => {
if (event.kind === 'delta') streamed += event.text;
if (event.kind === 'completed' && event.text) finalText = event.text;
if (event.kind === 'error') failure = event.message;
if (event.kind === 'session') return; // keep our prefixed id
onEvent(event);
});
setAbort(() => handle.abort());
await handle.done;
if (failure) throw new Error(failure);
return finalText || streamed;
}
private async sendViaCompletions(options: SendOptions, setAbort: (fn: () => void) => void): Promise<string> {
const { onEvent } = options;
const useTools = this.config.localTools && !!options.localToolExecutor && !!options.localToolDefinitions?.length;
const messages: HermesChatMessage[] = [];
if (this.config.instructions) messages.push({ role: 'system', content: this.config.instructions });
for (const h of options.history) messages.push({ role: h.role, content: h.content });
const userContent: HermesChatMessage['content'] = options.images?.length
? [{ type: 'text', text: options.text }, ...options.images.map((url) => ({ type: 'image_url', image_url: { url } }))]
: options.text;
messages.push({ role: 'user', content: userContent });
let aborted = false;
let currentHandle: StreamHandle | null = null;
setAbort(() => {
aborted = true;
currentHandle?.abort();
});
let fullText = '';
for (let iteration = 0; iteration < 6 && !aborted; iteration++) {
const payload: Rec = { model: this.config.model || 'hermes-agent', messages, stream: true };
if (useTools) {
payload.tools = options.localToolDefinitions;
payload.tool_choice = 'auto';
}
if (this.config.reasoningEffort) payload.model_options = { reasoning_effort: this.config.reasoningEffort };
const toolCalls = new Map<number, CompletionToolCallAccumulator>();
let finishReason: string | null = null;
let iterationText = '';
const errorChunks: string[] = [];
let ready = false;
const parser = new SseParser((message) => {
if (message.data === '[DONE]') return;
const data = tryParseJson<unknown>(message.data);
if (data === null) return;
if (message.event !== 'message' && !(isRec(data) && Array.isArray(data.choices))) {
for (const event of normalizeLifecycleEvent(message.event, data)) onEvent(event);
return;
}
const { events, finishReason: reason } = normalizeCompletionChunk(data, toolCalls);
for (const event of events) {
if (event.kind === 'delta') iterationText += event.text;
onEvent(event);
}
if (reason) finishReason = reason;
});
const headers = this.headers({ Accept: 'text/event-stream' });
if (options.sessionId) headers['X-Hermes-Session-Id'] = options.sessionId;
const handle = await httpStream(
{ url: `${this.base}/v1/chat/completions`, method: 'POST', headers, body: JSON.stringify(payload), timeoutMs: 10 * 60_000 },
{ onChunk: (text) => (ready ? parser.feed(text) : errorChunks.push(text)) }
);
currentHandle = handle;
if (!handle.start.ok) {
await handle.done.catch(() => undefined);
throw new HttpError(handle.start.status, errorMessage(handle.start.status, errorChunks.join('')));
}
ready = true;
const rotated = handle.start.headers['x-hermes-session-id'];
if (rotated && rotated !== options.sessionId) onEvent({ kind: 'session', sessionId: rotated });
await handle.done;
parser.end();
fullText += iterationText;
if (finishReason === 'tool_calls' && useTools && toolCalls.size > 0 && !aborted) {
const calls = [...toolCalls.values()];
messages.push({
role: 'assistant',
content: iterationText || null,
tool_calls: calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: c.arguments } }))
});
for (const call of calls) {
onEvent({ kind: 'tool.start', id: call.id, name: call.name, args: call.arguments });
const output = await options.localToolExecutor!(call.name, call.arguments);
onEvent({ kind: 'tool.end', id: call.id, name: call.name, output, ok: !output.includes('"error"') });
messages.push({ role: 'tool', content: output, tool_call_id: call.id });
}
continue;
}
break;
}
onEvent({ kind: 'completed', status: aborted ? 'cancelled' : 'completed', text: fullText });
return fullText;
}
}
export function normalizeJob(job: HermesJob): HermesJob {
const raw = job as Rec;
const id = String(raw.id ?? raw.job_id ?? raw.name ?? '');
return { ...job, id };
}
export function jobStatus(job: HermesJob): string {
if (job.state) return String(job.state);
if (job.status) return String(job.status);
if (job.enabled === false) return 'paused';
return 'active';
}
export function jobSchedule(job: HermesJob): string {
const s = job.schedule;
if (!s) return '—';
if (typeof s === 'string') return s;
return s.display || s.expr || s.kind || 'schedule';
}
export function jobOutput(job: HermesJob): string {
return String(job.last_output ?? job.last_error ?? job.output ?? job.result ?? '');
}
+236
View File
@@ -0,0 +1,236 @@
/**
* Normalises the various Hermes streaming dialects (runs events, sessions stream,
* chat-completions chunks) into one HermesStreamEvent vocabulary.
* Hermes releases have renamed a few events (tool.started/tool.start, token.delta/message.delta),
* so every reader here is deliberately tolerant.
*/
import type { HermesStreamEvent, HermesUsage } from './types';
type Rec = Record<string, unknown>;
const isRec = (v: unknown): v is Rec => !!v && typeof v === 'object' && !Array.isArray(v);
const s = (v: unknown): string => (typeof v === 'string' ? v : v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v));
const first = (obj: Rec, keys: string[]): unknown => {
for (const k of keys) if (obj[k] !== undefined && obj[k] !== null) return obj[k];
return undefined;
};
function textOf(data: Rec): string {
const direct = first(data, ['delta', 'text', 'content', 'output_text', 'token']);
if (typeof direct === 'string') return direct;
if (isRec(direct)) return s(first(direct, ['text', 'content', 'delta']));
return '';
}
function idOf(data: Rec, fallback = ''): string {
return s(first(data, ['tool_call_id', 'call_id', 'id', 'request_id', 'delegation_id'])) || fallback;
}
function toolName(data: Rec): string {
const direct = first(data, ['tool_name', 'tool', 'name', 'function']);
if (typeof direct === 'string') return direct;
if (isRec(direct)) return s(first(direct, ['name', 'tool']));
return 'outil';
}
function argsOf(data: Rec): string | undefined {
const value = first(data, ['arguments', 'args', 'input', 'params', 'preview']);
if (value === undefined) return undefined;
return typeof value === 'string' ? value : JSON.stringify(value);
}
function optionsOf(data: Rec): string[] | undefined {
const value = first(data, ['options', 'choices']);
if (!Array.isArray(value)) return undefined;
return value.map((v) => (isRec(v) ? s(first(v, ['label', 'value', 'id'])) : s(v))).filter(Boolean);
}
function usageOf(data: Rec): HermesUsage | undefined {
return isRec(data.usage) ? (data.usage as HermesUsage) : undefined;
}
/** Runs API + sessions stream share the same lifecycle vocabulary. */
export function normalizeLifecycleEvent(event: string, payload: unknown): HermesStreamEvent[] {
const name = event.toLowerCase();
const data: Rec = isRec(payload) ? payload : { value: payload };
const out: HermesStreamEvent[] = [];
const sessionId = s(first(data, ['session_id', 'sessionId']));
switch (name) {
case 'token.delta':
case 'message.delta':
case 'assistant.delta':
case 'output_text.delta':
case 'response.output_text.delta':
case 'delta': {
const text = textOf(data);
if (text) out.push({ kind: 'delta', text });
break;
}
case 'reasoning.delta':
case 'reasoning.available':
case 'thinking.delta': {
const text = textOf(data);
if (text) out.push({ kind: 'reasoning', text });
break;
}
case 'tool.start':
case 'tool.started':
case 'tool_call.start':
out.push({ kind: 'tool.start', id: idOf(data, `tool-${Date.now()}`), name: toolName(data), args: argsOf(data) });
break;
case 'tool.progress':
case 'hermes.tool.progress':
out.push({
kind: 'tool.progress',
id: idOf(data) || undefined,
name: toolName(data),
label: s(first(data, ['label', 'message', 'status'])) || undefined,
detail: s(first(data, ['preview', 'detail', 'output'])) || undefined
});
break;
case 'tool.complete':
case 'tool.completed':
case 'tool.end':
case 'tool_call.complete': {
const status = s(first(data, ['status', 'result_status'])).toLowerCase();
const error = first(data, ['error']);
out.push({
kind: 'tool.end',
id: idOf(data, ''),
name: data.tool_name || data.tool || data.name ? toolName(data) : undefined,
output: s(first(data, ['output', 'result', 'content'])) || undefined,
ok: !error && status !== 'error' && status !== 'failed'
});
break;
}
case 'subagent.start':
out.push({
kind: 'subagent.start',
id: s(first(data, ['delegation_id', 'id'])) || `sub-${Date.now()}`,
childSessionId: s(data.child_session_id) || undefined,
label: s(first(data, ['label', 'task', 'goal'])) || undefined
});
break;
case 'subagent.complete':
case 'subagent.end':
out.push({
kind: 'subagent.end',
id: s(first(data, ['delegation_id', 'id'])) || 'sub',
status: s(data.status) || 'completed',
summary: s(data.summary) || undefined,
durationMs: typeof data.duration_ms === 'number' ? data.duration_ms : undefined
});
break;
case 'approval.request':
case 'approval.requested':
out.push({
kind: 'approval.request',
requestId: s(first(data, ['request_id', 'approval_id', 'id'])),
tool: s(first(data, ['tool_name', 'tool', 'command'])) || undefined,
description: s(first(data, ['description', 'message', 'prompt', 'reason', 'command', 'preview'])) || 'Hermes demande une autorisation.',
args: argsOf(data),
options: optionsOf(data)
});
break;
case 'clarify.request':
case 'clarification.request':
out.push({
kind: 'clarify.request',
requestId: s(first(data, ['request_id', 'id'])),
question: s(first(data, ['question', 'prompt', 'message'])) || 'Hermes a besoin d’une précision.',
options: optionsOf(data)
});
break;
case 'sudo.request':
case 'secret.request':
out.push({
kind: 'input.request',
requestId: s(first(data, ['request_id', 'id'])),
prompt: s(first(data, ['prompt', 'message', 'description'])) || (name === 'sudo.request' ? 'Mot de passe sudo requis' : 'Secret requis'),
secret: true,
variant: name === 'sudo.request' ? 'sudo' : 'secret'
});
break;
case 'approval.expire':
case 'clarify.expire':
case 'sudo.expire':
case 'secret.expire':
case 'request.expire':
out.push({ kind: 'request.expire', requestId: s(first(data, ['request_id', 'id'])) });
break;
case 'run.started':
case 'run.created':
out.push({ kind: 'run.started', runId: s(first(data, ['run_id', 'id'])) });
break;
case 'message.complete':
case 'message.completed':
case 'assistant.complete': {
// Final text; a run.completed usually follows, so only surface the text here.
const text = textOf(data);
if (text) out.push({ kind: 'completed', text, status: 'message', usage: usageOf(data) });
break;
}
case 'run.completed':
case 'run.complete':
case 'run.finished':
case 'done':
case 'complete':
out.push({
kind: 'completed',
text: s(first(data, ['output', 'text', 'content', 'final_output'])) || undefined,
status: s(data.status) || 'completed',
usage: usageOf(data)
});
break;
case 'run.failed':
case 'run.cancelled':
case 'run.canceled':
case 'error':
out.push({ kind: 'error', message: s(first(data, ['error', 'message', 'detail', 'reason'])) || name });
break;
case 'gateway.ready':
case 'ping':
case 'heartbeat':
break;
default:
out.push({ kind: 'raw', event, data: payload });
}
if (sessionId) out.push({ kind: 'session', sessionId });
return out;
}
export interface CompletionToolCallAccumulator {
id: string;
name: string;
arguments: string;
}
/** Chat-completions chunk (OpenAI format). Returns delta events plus any accumulated tool calls. */
export function normalizeCompletionChunk(
chunk: unknown,
toolCalls: Map<number, CompletionToolCallAccumulator>
): { events: HermesStreamEvent[]; finishReason: string | null } {
const events: HermesStreamEvent[] = [];
if (!isRec(chunk)) return { events, finishReason: null };
const choice = Array.isArray(chunk.choices) && isRec(chunk.choices[0]) ? (chunk.choices[0] as Rec) : null;
if (!choice) return { events, finishReason: null };
const delta = isRec(choice.delta) ? (choice.delta as Rec) : isRec(choice.message) ? (choice.message as Rec) : null;
if (delta) {
if (typeof delta.content === 'string' && delta.content) events.push({ kind: 'delta', text: delta.content });
const reasoning = first(delta, ['reasoning_content', 'reasoning']);
if (typeof reasoning === 'string' && reasoning) events.push({ kind: 'reasoning', text: reasoning });
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) {
if (!isRec(tc)) continue;
const index = typeof tc.index === 'number' ? tc.index : toolCalls.size;
const fn = isRec(tc.function) ? (tc.function as Rec) : {};
const acc = toolCalls.get(index) ?? { id: s(tc.id), name: '', arguments: '' };
if (tc.id) acc.id = s(tc.id);
if (typeof fn.name === 'string') acc.name += fn.name;
if (typeof fn.arguments === 'string') acc.arguments += fn.arguments;
toolCalls.set(index, acc);
}
}
}
return { events, finishReason: typeof choice.finish_reason === 'string' ? choice.finish_reason : null };
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Client-side tools offered to Hermes in chat-completions mode (OpenAI tool calling).
* With the runs/sessions transports Hermes uses its own server-side toolsets instead.
*/
import { bridge } from '../../lib/bridge';
export interface LocalToolContext {
setEmotion: (emotion: string) => void;
speak: (text: string) => void;
getStatus: () => Record<string, unknown>;
getHistory: (n: number) => Array<{ role: string; content: string }>;
notify: (title: string, body: string) => void;
}
export const LOCAL_TOOL_DEFINITIONS = [
{
type: 'function',
function: {
name: 'set_hud_state',
description: "Change l'état visuel du HUD EveFlow (couleur/animation du noyau) pour accompagner la réponse.",
parameters: {
type: 'object',
properties: {
state: { type: 'string', enum: ['neutral', 'happy', 'thinking', 'alert', 'error', 'success'] }
},
required: ['state']
}
}
},
{
type: 'function',
function: {
name: 'get_app_status',
description: "Retourne l'état courant d'EveFlow (transport, voix, nombre de messages, hôte).",
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'get_conversation_history',
description: 'Retourne les N derniers messages affichés dans EveFlow.',
parameters: { type: 'object', properties: { n: { type: 'number', minimum: 1, maximum: 30 } }, required: ['n'] }
}
},
{
type: 'function',
function: {
name: 'speak_text',
description: 'Fait prononcer immédiatement un texte court par la voix du HUD.',
parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }
}
},
{
type: 'function',
function: {
name: 'notify_user',
description: 'Affiche une notification système Windows à l’utilisateur.',
parameters: { type: 'object', properties: { title: { type: 'string' }, body: { type: 'string' } }, required: ['title', 'body'] }
}
},
{
type: 'function',
function: {
name: 'write_shared_file',
description: "Écrit un fichier texte (rapport, script, JSON, HTML, SVG) dans le dossier partagé EveFlow_Shared de l'utilisateur et renvoie son URL.",
parameters: {
type: 'object',
properties: { filename: { type: 'string' }, content: { type: 'string' } },
required: ['filename', 'content']
}
}
}
];
export async function executeLocalTool(name: string, rawArgs: string, ctx: LocalToolContext): Promise<string> {
let args: Record<string, unknown> = {};
try {
args = rawArgs ? (JSON.parse(rawArgs) as Record<string, unknown>) : {};
} catch {
return JSON.stringify({ error: 'invalid JSON arguments' });
}
try {
switch (name) {
case 'set_hud_state':
ctx.setEmotion(String(args.state ?? 'neutral'));
return JSON.stringify({ ok: true });
case 'get_app_status':
return JSON.stringify(ctx.getStatus());
case 'get_conversation_history':
return JSON.stringify({ messages: ctx.getHistory(Math.min(30, Number(args.n) || 5)) });
case 'speak_text':
ctx.speak(String(args.text ?? '').slice(0, 600));
return JSON.stringify({ ok: true });
case 'notify_user':
ctx.notify(String(args.title ?? 'EveFlow'), String(args.body ?? ''));
return JSON.stringify({ ok: true });
case 'write_shared_file': {
const api = bridge();
if (!api) return JSON.stringify({ error: 'file system unavailable outside Electron' });
const result = await api.files.writeShared(String(args.filename ?? 'fichier.txt'), String(args.content ?? ''), false);
return JSON.stringify({ ok: true, ...result });
}
default:
return JSON.stringify({ error: `unknown tool ${name}` });
}
} catch (err) {
return JSON.stringify({ error: (err as Error).message });
}
}
+174
View File
@@ -0,0 +1,174 @@
export type HermesTransport = 'auto' | 'runs' | 'sessions' | 'completions';
export interface HermesConfig {
url: string;
apiKey: string;
model: string;
/** Stable per-user key for long-term memory (X-Hermes-Session-Key). */
sessionKey: string;
transport: HermesTransport;
reasoningEffort: '' | 'low' | 'medium' | 'high';
/** Extra instructions layered on top of the Hermes system prompt. */
instructions: string;
/** Expose EveFlow client tools in chat-completions mode. */
localTools: boolean;
}
export interface HermesCapabilities {
object?: string;
platform?: string;
model?: string;
version?: string;
auth?: { type?: string; required?: boolean };
features?: Record<string, boolean | string>;
endpoints?: Record<string, boolean>;
[key: string]: unknown;
}
export interface HermesHealth {
status: string;
readiness?: { checks?: Record<string, unknown> };
[key: string]: unknown;
}
export interface HermesModel {
id: string;
owned_by?: string;
provider?: string;
[key: string]: unknown;
}
export interface HermesSkill {
name: string;
description?: string;
category?: string;
[key: string]: unknown;
}
export interface HermesToolset {
name: string;
label?: string;
description?: string;
enabled?: boolean;
configured?: boolean;
tools?: string[];
[key: string]: unknown;
}
export interface HermesSession {
id: string;
title?: string;
created_at?: string | number;
updated_at?: string | number;
end_reason?: string | null;
message_count?: number;
platform?: string;
source?: string;
[key: string]: unknown;
}
export interface HermesSessionMessage {
role: string;
content: string;
timestamp?: string | number;
tool_calls?: unknown[];
[key: string]: unknown;
}
export interface HermesJobSchedule {
kind?: string;
expr?: string;
display?: string;
}
export interface HermesJob {
id: string;
name?: string;
prompt?: string;
schedule?: string | HermesJobSchedule;
state?: string;
status?: string;
enabled?: boolean;
deliver?: string;
next_run_at?: string | number;
last_run_at?: string | number;
last_status?: string;
last_output?: string;
last_error?: string;
repeat?: number | { times?: number | null; completed?: number };
skills?: string[];
skill?: string;
provider?: string | null;
model?: string | null;
created_at?: string | number;
updated_at?: string | number;
[key: string]: unknown;
}
export interface HermesJobDraft {
name: string;
schedule: string;
prompt: string;
deliver?: string;
skills?: string[];
}
export interface HermesRunInfo {
run_id: string;
status: string;
session_id?: string;
model?: string;
output?: string;
usage?: Record<string, number>;
[key: string]: unknown;
}
export interface HermesUsage {
input_tokens?: number;
output_tokens?: number;
total_tokens?: number;
[key: string]: unknown;
}
export type HermesStreamEvent =
| { kind: 'delta'; text: string }
| { kind: 'reasoning'; text: string }
| { kind: 'tool.start'; id: string; name: string; args?: string }
| { kind: 'tool.progress'; id?: string; name: string; label?: string; detail?: string }
| { kind: 'tool.end'; id: string; name?: string; output?: string; ok: boolean }
| { kind: 'subagent.start'; id: string; childSessionId?: string; label?: string }
| { kind: 'subagent.end'; id: string; status: string; summary?: string; durationMs?: number }
| { kind: 'approval.request'; requestId: string; tool?: string; description: string; args?: string; options?: string[] }
| { kind: 'clarify.request'; requestId: string; question: string; options?: string[] }
| { kind: 'input.request'; requestId: string; prompt: string; secret: boolean; variant: 'sudo' | 'secret' }
| { kind: 'request.expire'; requestId: string }
| { kind: 'session'; sessionId: string }
| { kind: 'run.started'; runId: string }
| { kind: 'completed'; text?: string; status: string; usage?: HermesUsage }
| { kind: 'error'; message: string }
| { kind: 'raw'; event: string; data: unknown };
export interface HermesChatMessage {
role: 'user' | 'assistant' | 'system' | 'tool';
content: string | null | Array<{ type: string; text?: string; image_url?: { url: string } }>;
tool_call_id?: string;
tool_calls?: unknown[];
}
export interface SendOptions {
text: string;
images?: string[];
/** Recent turns used only by the stateless chat-completions transport. */
history: Array<{ role: 'user' | 'assistant'; content: string }>;
sessionId: string;
onEvent: (event: HermesStreamEvent) => void;
localToolExecutor?: (name: string, args: string) => Promise<string>;
localToolDefinitions?: unknown[];
}
export interface SendHandle {
/** Resolves with the final assistant text. */
result: Promise<string>;
abort: () => void;
transport: Exclude<HermesTransport, 'auto'>;
}