feat: land native mobile foundation
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
Reference in New Issue
Block a user