feat: land native mobile foundation

This commit is contained in:
Hermes Agent
2026-07-24 07:16:39 +00:00
parent 73e17676b5
commit 26bff1174d
11 changed files with 1151 additions and 378 deletions
+1
View File
@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import { timingSafeEqual } from 'node:crypto';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { authValidateResponseSchema } from '@hermes-mobile/shared';
+113 -23
View File
@@ -5,33 +5,123 @@ import { toSafeAbsolutePath } from '../config/paths.js';
import { getHermesHealth } from '../system/hermesHealth.js';
import { runProcess } from '../system/processes.js';
const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[;?0-9]*[ -/]*[@-~]`, 'g');
export async function registerChatRoutes(app: FastifyInstance, config: CompanionConfig): Promise<void> {
app.post('/api/chat', async (request, reply) => {
const body = chatRequestSchema.parse(request.body);
const hermesHealth = await getHermesHealth();
if (!hermesHealth.cliAvailable) {
return chatResponseSchema.parse({
reply: 'Hermes CLI was not found on this machine. Install Hermes or make sure `hermes` is on PATH, then retry.',
exitCode: null,
stderr: undefined,
hermesAvailable: false,
});
const body = chatRequestSchema.safeParse(request.body);
if (!body.success) {
return reply.code(400).send(
chatResponseSchema.parse({
reply: 'Chat prompt is required.',
exitCode: null,
stderr: undefined,
hermesAvailable: false,
}),
);
}
const result = await runProcess(hermesHealth.cliPath ?? 'hermes', [], {
cwd: toSafeAbsolutePath(config.workspaceRoot),
timeoutMs: 30000,
input: body.prompt,
});
try {
const hermesHealth = await getHermesHealth();
return reply.code(result.exitCode === 0 || result.exitCode === null ? 200 : 502).send(
chatResponseSchema.parse({
reply: result.stdout.trim() || result.stderr.trim() || 'Hermes finished without output.',
exitCode: result.exitCode,
stderr: result.stderr || undefined,
hermesAvailable: true,
}),
);
if (!hermesHealth.cliAvailable) {
return reply.send(
chatResponseSchema.parse({
reply: 'Hermes CLI was not found on this machine. Install Hermes or make sure `hermes` is on PATH, then retry.',
exitCode: null,
stderr: undefined,
hermesAvailable: false,
}),
);
}
const result = await runProcess(hermesHealth.cliPath ?? 'hermes', ['chat', '-Q', '--source', 'tool', '-q', body.data.prompt], {
cwd: toSafeAbsolutePath(config.workspaceRoot),
timeoutMs: 120000,
});
const cleanStdout = sanitizeHermesOutput(result.stdout);
const cleanStderr = sanitizeHermesOutput(result.stderr);
const openedHelp = looksLikeTopLevelHermesHelp(result.stdout) || looksLikeTopLevelHermesHelp(result.stderr);
const spawnFailed = result.exitCode === null && cleanStderr.length > 0 && cleanStdout.length === 0;
const failed = result.timedOut || openedHelp || spawnFailed || (result.exitCode !== 0 && result.exitCode !== null);
const fallback = openedHelp
? 'Hermes opened its CLI help instead of running the prompt. Restart the companion server so it uses the updated chat command.'
: failed
? `Hermes did not finish successfully${result.exitCode === null ? '' : ` (exit ${result.exitCode})`}.${result.timedOut ? ' The request timed out.' : ''}`
: 'Hermes finished without output.';
const responseText = (openedHelp ? '' : cleanStdout) || (openedHelp ? '' : cleanStderr) || fallback;
return reply.code(failed ? (result.timedOut ? 504 : 502) : 200).send(
chatResponseSchema.parse({
reply: truncateForMobile(responseText),
exitCode: result.exitCode,
stderr: cleanStderr ? truncateForMobile(cleanStderr) : undefined,
hermesAvailable: true,
}),
);
} catch (error) {
const message = error instanceof Error ? error.message : 'Chat request failed.';
return reply.send(
chatResponseSchema.parse({
reply: sanitizeHermesOutput(message) || 'Chat request failed.',
exitCode: null,
stderr: undefined,
hermesAvailable: true,
}),
);
}
});
}
function sanitizeHermesOutput(text: string): string {
return text
.replace(ansiPattern, '')
.replace(/\r/g, '')
.split('\n')
.filter((line) => !isBannerOrHelpLine(line))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function isBannerOrHelpLine(line: string): boolean {
const trimmed = line.trim();
if (!trimmed) return false;
if (/session_id\s*:/i.test(trimmed)) return true;
if (/[\u2500-\u257F]/u.test(trimmed)) return true;
if (/^[+\-_|/\\()[\]{}<>\s*]+$/.test(trimmed) && trimmed.length > 8) return true;
if (/^(usage:|positional arguments:|options:|examples:|for more help on a command:|hermes <command> --help)/i.test(trimmed)) return true;
if (/^\{chat,model,gateway,setup,whatsapp,login,logout,auth,status,cron,webhook,doctor,config,pairing,skills,plugins,honcho,tools,mcp,sessions,insights,claw,version,update,uninstall,acp,profile,completion\}/i.test(trimmed)) return true;
if (/^(chat|model|gateway|setup|whatsapp|login|logout|auth|status|cron|webhook|doctor|config|pairing|skills|plugins|honcho|tools|mcp|sessions|insights|claw|version|update|uninstall|acp|profile|completion)\s{2,}/i.test(trimmed)) return true;
if (/^hermes\s+(chat|setup|gateway|status|doctor|model|config|sessions|update|uninstall)\b/i.test(trimmed)) return true;
if (/^(Hermes|⚕ Hermes)\b.*(Agent|CLI|v?\d)/i.test(trimmed)) return true;
if (/^Hermes Agent - AI assistant with tool-calling capabilities$/i.test(trimmed)) return true;
return false;
}
function looksLikeTopLevelHermesHelp(text: string): boolean {
const clean = text.replace(ansiPattern, '');
return /usage:\s+hermes\b/i.test(clean) && /Hermes Agent - AI assistant with tool-calling capabilities/i.test(clean);
}
function selfCheckSanitizer(): void {
const sample = [
'\u001B[36m╭─ ⚕ Hermes v1.2.3 ─╮\u001B[0m',
'│ session_id: abc123 │',
'╰────────────────────╯',
'',
'Final answer line.',
].join('\n');
if (sanitizeHermesOutput(sample) !== 'Final answer line.') {
throw new Error('Hermes output sanitizer self-check failed.');
}
}
selfCheckSanitizer();
function truncateForMobile(text: string): string {
const maxChars = 24000;
if (text.length <= maxChars) return text;
return `${text.slice(0, maxChars)}\n\n[Output truncated]`;
}
+1
View File
@@ -1,3 +1,4 @@
import { Buffer } from 'node:buffer';
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import type { FastifyInstance } from 'fastify';
+1
View File
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import { clearTimeout, setTimeout } from 'node:timers';
export type RunProcessResult = {
stdout: string;
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@ import { useCompanion } from '../../api/CompanionContext.js';
import { CompanionClient, DEFAULT_COMPANION_URL } from '../../api/companionClient.js';
export function SettingsScreen() {
const { client, settings, updateSettings } = useCompanion();
const { settings, updateSettings } = useCompanion();
const [baseUrl, setBaseUrl] = useState(settings.baseUrl);
const [accessKey, setAccessKey] = useState(settings.accessKey);
const [status, setStatus] = useState('Run companion setup, paste the access key, then test the connection.');