mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 18:13:03 +08:00
feat: add Hermes provider
This commit is contained in:
135
server/modules/providers/list/hermes/hermes-auth.provider.ts
Normal file
135
server/modules/providers/list/hermes/hermes-auth.provider.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
export class HermesProviderAuth implements IProviderAuth {
|
||||
private checkInstalled(): boolean {
|
||||
const cliPath = process.env.HERMES_CLI_PATH || 'hermes acp';
|
||||
const [command, ...args] = cliPath.trim().split(/\s+/);
|
||||
try {
|
||||
const result = spawn.sync(command || 'hermes', [...args, '--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return result.error ? false : result.status === 0 || result.status === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
if (!installed) {
|
||||
return {
|
||||
provider: 'hermes',
|
||||
installed: false,
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Hermes ACP is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.checkCredentials();
|
||||
return {
|
||||
provider: 'hermes',
|
||||
installed,
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : 'Hermes credentials were not found',
|
||||
};
|
||||
}
|
||||
|
||||
private async checkCredentials(): Promise<{ authenticated: boolean; email: string | null; method: string | null }> {
|
||||
if (this.hasKnownProviderEnv(process.env)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'env' };
|
||||
}
|
||||
|
||||
const hermesHome = path.join(os.homedir(), '.hermes');
|
||||
try {
|
||||
const authJson = readObjectRecord(JSON.parse(await readFile(path.join(hermesHome, 'auth.json'), 'utf8')));
|
||||
if (
|
||||
readOptionalString(authJson?.apiKey)
|
||||
|| readOptionalString(authJson?.api_key)
|
||||
|| readOptionalString(authJson?.token)
|
||||
|| readOptionalString(authJson?.access_token)
|
||||
|| readOptionalString(authJson?.refresh_token)
|
||||
) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: readOptionalString(authJson?.email) ?? 'Hermes Auth',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to dotenv check.
|
||||
}
|
||||
|
||||
try {
|
||||
const envContent = await readFile(path.join(hermesHome, '.env'), 'utf8');
|
||||
if (this.hasKnownProviderEnv(this.parseEnvFile(envContent))) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'env_file' };
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
|
||||
try {
|
||||
const configContent = await readFile(path.join(hermesHome, 'config.yaml'), 'utf8');
|
||||
if (/^\s*api_key\s*:\s*["']?[^"'#\s]+/m.test(configContent)) {
|
||||
return { authenticated: true, email: 'Hermes Config', method: 'config_file' };
|
||||
}
|
||||
} catch {
|
||||
// Fall through.
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, method: null };
|
||||
}
|
||||
|
||||
private parseEnvFile(content: string): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const value = line.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/g, '');
|
||||
if (key && value) {
|
||||
parsed[key] = value;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private hasKnownProviderEnv(env: Record<string, string | undefined>): boolean {
|
||||
const keys = [
|
||||
'HERMES_API_KEY',
|
||||
'NOUS_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GLM_API_KEY',
|
||||
'KIMI_API_KEY',
|
||||
'MINIMAX_API_KEY',
|
||||
'MINIMAX_CN_API_KEY',
|
||||
'HF_TOKEN',
|
||||
'NVIDIA_API_KEY',
|
||||
'ARCEEAI_API_KEY',
|
||||
'OLLAMA_API_KEY',
|
||||
'KILOCODE_API_KEY',
|
||||
'GITHUB_TOKEN',
|
||||
];
|
||||
return keys.some((key) => Boolean(env[key]?.trim()));
|
||||
}
|
||||
}
|
||||
296
server/modules/providers/list/hermes/hermes-mcp.provider.ts
Normal file
296
server/modules/providers/list/hermes/hermes-mcp.provider.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const yamlScalar = (value: unknown): string => {
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
if (value === null) {
|
||||
return 'null';
|
||||
}
|
||||
return JSON.stringify(String(value));
|
||||
};
|
||||
|
||||
const parseYamlScalar = (value: string): unknown => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
if (trimmed === 'null') {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (trimmed === 'false') {
|
||||
return false;
|
||||
}
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('\'') && trimmed.endsWith('\''))) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
}
|
||||
if (
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||
|| (trimmed.startsWith('{') && trimmed.endsWith('}'))
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return trimmed.replace(/\s+#.*$/, '').trim();
|
||||
};
|
||||
|
||||
const getIndent = (line: string): number => line.match(/^\s*/)?.[0].length ?? 0;
|
||||
|
||||
const parseYamlArray = (
|
||||
lines: string[],
|
||||
startIndex: number,
|
||||
indent: number,
|
||||
): { value: unknown[]; nextIndex: number } => {
|
||||
const value: unknown[] = [];
|
||||
let index = startIndex;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (getIndent(line) !== indent || !line.trimStart().startsWith('- ')) {
|
||||
break;
|
||||
}
|
||||
value.push(parseYamlScalar(line.trimStart().slice(2)));
|
||||
index += 1;
|
||||
}
|
||||
return { value, nextIndex: index };
|
||||
};
|
||||
|
||||
const parseYamlMap = (
|
||||
lines: string[],
|
||||
startIndex: number,
|
||||
indent: number,
|
||||
): { value: Record<string, unknown>; nextIndex: number } => {
|
||||
const value: Record<string, unknown> = {};
|
||||
let index = startIndex;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const currentIndent = getIndent(line);
|
||||
if (currentIndent < indent) {
|
||||
break;
|
||||
}
|
||||
if (currentIndent > indent) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const match = line.slice(indent).match(/^([^:#]+):(?:\s*(.*))?$/);
|
||||
if (!match) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1].trim();
|
||||
const raw = match[2]?.trim() ?? '';
|
||||
if (raw) {
|
||||
value[key] = parseYamlScalar(raw);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextLine = lines[index + 1];
|
||||
if (nextLine && getIndent(nextLine) > indent && nextLine.trimStart().startsWith('- ')) {
|
||||
const parsed = parseYamlArray(lines, index + 1, getIndent(nextLine));
|
||||
value[key] = parsed.value;
|
||||
index = parsed.nextIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseYamlMap(lines, index + 1, indent + 2);
|
||||
value[key] = parsed.value;
|
||||
index = parsed.nextIndex;
|
||||
}
|
||||
return { value, nextIndex: index };
|
||||
};
|
||||
|
||||
const readYamlConfig = async (filePath: string): Promise<string> => {
|
||||
try {
|
||||
return await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return '';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const readMcpServers = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
const content = await readYamlConfig(filePath);
|
||||
const lines = content.split(/\r?\n/);
|
||||
const sectionIndex = lines.findIndex((line) => /^mcp_servers\s*:\s*$/.test(line));
|
||||
if (sectionIndex === -1) {
|
||||
return {};
|
||||
}
|
||||
const parsed = parseYamlMap(lines, sectionIndex + 1, 2);
|
||||
return readObjectRecord(parsed.value) ?? {};
|
||||
};
|
||||
|
||||
const serializeYamlMap = (value: Record<string, unknown>, indent = 0): string[] => {
|
||||
const lines: string[] = [];
|
||||
for (const [key, rawValue] of Object.entries(value)) {
|
||||
if (rawValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
const prefix = `${' '.repeat(indent)}${key}:`;
|
||||
if (Array.isArray(rawValue)) {
|
||||
lines.push(prefix);
|
||||
for (const item of rawValue) {
|
||||
lines.push(`${' '.repeat(indent + 2)}- ${yamlScalar(item)}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const nested = readObjectRecord(rawValue);
|
||||
if (nested) {
|
||||
lines.push(prefix);
|
||||
lines.push(...serializeYamlMap(nested, indent + 2));
|
||||
continue;
|
||||
}
|
||||
lines.push(`${prefix} ${yamlScalar(rawValue)}`);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const replaceMcpServersSection = (content: string, servers: Record<string, unknown>): string => {
|
||||
const lines = content.split(/\r?\n/);
|
||||
const sectionIndex = lines.findIndex((line) => /^mcp_servers\s*:\s*$/.test(line));
|
||||
const serialized = ['mcp_servers:', ...serializeYamlMap(servers, 2)];
|
||||
|
||||
if (sectionIndex === -1) {
|
||||
const prefix = content.trimEnd();
|
||||
return `${prefix ? `${prefix}\n\n` : ''}${serialized.join('\n')}\n`;
|
||||
}
|
||||
|
||||
let endIndex = sectionIndex + 1;
|
||||
while (endIndex < lines.length) {
|
||||
const line = lines[endIndex];
|
||||
if (line.trim() && getIndent(line) === 0) {
|
||||
break;
|
||||
}
|
||||
endIndex += 1;
|
||||
}
|
||||
|
||||
lines.splice(sectionIndex, endIndex - sectionIndex, ...serialized);
|
||||
return `${lines.join('\n').trimEnd()}\n`;
|
||||
};
|
||||
|
||||
const writeMcpServers = async (filePath: string, servers: Record<string, unknown>): Promise<void> => {
|
||||
const content = await readYamlConfig(filePath);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, replaceMcpServersSection(content, servers), 'utf8');
|
||||
};
|
||||
|
||||
export class HermesMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('hermes', ['user', 'project'], ['stdio', 'http']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.hermes', 'config.yaml')
|
||||
: path.join(workspacePath, '.hermes', 'config.yaml');
|
||||
return readMcpServers(filePath);
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.hermes', 'config.yaml')
|
||||
: path.join(workspacePath, '.hermes', 'config.yaml');
|
||||
await writeMcpServers(filePath, servers);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: input.transport,
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(scope: McpScope, name: string, rawConfig: unknown): ProviderMcpServer | null {
|
||||
const config = readObjectRecord(rawConfig);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'hermes',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
return {
|
||||
provider: 'hermes',
|
||||
name,
|
||||
scope,
|
||||
transport: 'http',
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
152
server/modules/providers/list/hermes/hermes-models.provider.ts
Normal file
152
server/modules/providers/list/hermes/hermes-models.provider.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
readOptionalString,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const HERMES_CONFIGURED_MODEL = '__hermes_configured_model__';
|
||||
|
||||
export const HERMES_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{
|
||||
value: HERMES_CONFIGURED_MODEL,
|
||||
label: 'Configured in Hermes',
|
||||
description: 'Uses the provider and model selected with `hermes model`.',
|
||||
},
|
||||
],
|
||||
DEFAULT: HERMES_CONFIGURED_MODEL,
|
||||
};
|
||||
|
||||
const HERMES_CONFIG_PATH = path.join(os.homedir(), '.hermes', 'config.yaml');
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function stripScalar(raw: string): string | null {
|
||||
let value = raw.trim();
|
||||
// Drop an unquoted trailing comment.
|
||||
if (!value.startsWith('"') && !value.startsWith("'")) {
|
||||
const comment = value.search(/\s#/);
|
||||
if (comment >= 0) {
|
||||
value = value.slice(0, comment).trim();
|
||||
}
|
||||
}
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
return value.trim() || null;
|
||||
}
|
||||
|
||||
const indentOf = (line: string): number => line.length - line.replace(/^\s+/, '').length;
|
||||
|
||||
// Minimal, indentation-aware reader for the flat `key: value` and one-level
|
||||
// nested (`section:`\n` key: value`) shapes used by ~/.hermes/config.yaml.
|
||||
// Avoids the fragile single-regex lookahead that could terminate a section
|
||||
// early and silently miss the configured model.
|
||||
export function readYamlPath(content: string, pathParts: string[]): string | null {
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
if (pathParts.length === 1) {
|
||||
const re = new RegExp(`^\\s*${escapeRegex(pathParts[0])}\\s*:\\s*(.*)$`);
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
const match = line.match(re);
|
||||
if (match) return stripScalar(match[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [section, key] = pathParts;
|
||||
const sectionRe = new RegExp(`^(\\s*)${escapeRegex(section)}\\s*:\\s*$`);
|
||||
const keyRe = new RegExp(`^\\s*${escapeRegex(key)}\\s*:\\s*(.*)$`);
|
||||
let sectionIndent: number | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim() || line.trim().startsWith('#')) continue;
|
||||
|
||||
if (sectionIndent === null) {
|
||||
const match = line.match(sectionRe);
|
||||
if (match) sectionIndent = match[1].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Left the nested block once indentation returns to the section level or less.
|
||||
if (indentOf(line) <= sectionIndent) {
|
||||
sectionIndent = line.match(sectionRe)?.[1].length ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = line.match(keyRe);
|
||||
if (match) return stripScalar(match[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export class HermesProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
const activeModel = await this.readConfiguredModel();
|
||||
if (!activeModel) {
|
||||
return HERMES_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ value: activeModel, label: activeModel },
|
||||
...HERMES_FALLBACK_MODELS.OPTIONS,
|
||||
];
|
||||
|
||||
return {
|
||||
OPTIONS: options,
|
||||
DEFAULT: activeModel,
|
||||
};
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||
const configured = await this.readConfiguredModel();
|
||||
if (configured) {
|
||||
return { model: configured };
|
||||
}
|
||||
|
||||
return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels());
|
||||
}
|
||||
|
||||
async changeActiveModel(input: ProviderChangeActiveModelInput): Promise<ProviderSessionActiveModelChange> {
|
||||
if (input.model === HERMES_CONFIGURED_MODEL) {
|
||||
return {
|
||||
provider: 'hermes',
|
||||
sessionId: input.sessionId,
|
||||
supported: true,
|
||||
changed: false,
|
||||
model: null,
|
||||
};
|
||||
}
|
||||
|
||||
return writeProviderSessionActiveModelChange('hermes', input);
|
||||
}
|
||||
|
||||
private async readConfiguredModel(): Promise<string | null> {
|
||||
try {
|
||||
const content = await readFile(HERMES_CONFIG_PATH, 'utf8');
|
||||
return readOptionalString(readYamlPath(content, ['model', 'default']))
|
||||
?? readOptionalString(readYamlPath(content, ['model']))
|
||||
?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import fsSync from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
import { normalizeSessionName } from '@/shared/utils.js';
|
||||
|
||||
type HermesSessionRow = {
|
||||
id: string;
|
||||
cwd: string | null;
|
||||
title: string | null;
|
||||
started_at: number | null;
|
||||
ended_at: number | null;
|
||||
message_count: number | null;
|
||||
};
|
||||
|
||||
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
|
||||
|
||||
function unixSecondsToIso(value: number | null | undefined): string {
|
||||
if (!value || !Number.isFinite(value)) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
|
||||
function openHermesDatabase(): Database.Database | null {
|
||||
if (!fsSync.existsSync(HERMES_DB_PATH)) {
|
||||
return null;
|
||||
}
|
||||
return new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
|
||||
}
|
||||
|
||||
export class HermesSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'hermes' as const;
|
||||
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const db = openHermesDatabase();
|
||||
if (!db) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = since
|
||||
? db.prepare(`
|
||||
SELECT id, cwd, title, started_at, ended_at, message_count
|
||||
FROM sessions
|
||||
WHERE COALESCE(ended_at, started_at) >= ?
|
||||
ORDER BY COALESCE(ended_at, started_at) ASC
|
||||
`).all(Math.floor(since.getTime() / 1000)) as HermesSessionRow[]
|
||||
: db.prepare(`
|
||||
SELECT id, cwd, title, started_at, ended_at, message_count
|
||||
FROM sessions
|
||||
ORDER BY COALESCE(ended_at, started_at) ASC
|
||||
`).all() as HermesSessionRow[];
|
||||
|
||||
let processed = 0;
|
||||
for (const row of rows) {
|
||||
if (this.upsertRow(row)) {
|
||||
processed += 1;
|
||||
}
|
||||
}
|
||||
return processed;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
if (path.resolve(filePath) !== HERMES_DB_PATH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const db = openHermesDatabase();
|
||||
if (!db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const row = db.prepare(`
|
||||
SELECT id, cwd, title, started_at, ended_at, message_count
|
||||
FROM sessions
|
||||
ORDER BY COALESCE(ended_at, started_at) DESC
|
||||
LIMIT 1
|
||||
`).get() as HermesSessionRow | undefined;
|
||||
return row && this.upsertRow(row) ? row.id : null;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
private upsertRow(row: HermesSessionRow): boolean {
|
||||
if (!row.id || !row.cwd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
sessionsDb.createSession(
|
||||
row.id,
|
||||
this.provider,
|
||||
row.cwd,
|
||||
normalizeSessionName(row.title ?? undefined, 'Untitled Hermes Session'),
|
||||
unixSecondsToIso(row.started_at),
|
||||
unixSecondsToIso(row.ended_at ?? row.started_at),
|
||||
HERMES_DB_PATH,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
307
server/modules/providers/list/hermes/hermes-sessions.provider.ts
Normal file
307
server/modules/providers/list/hermes/hermes-sessions.provider.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import fsSync from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import {
|
||||
createNormalizedMessage,
|
||||
generateMessageId,
|
||||
normalizeProviderTimestamp,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
sliceTailPage,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'hermes';
|
||||
const HERMES_DB_PATH = path.join(os.homedir(), '.hermes', 'state.db');
|
||||
|
||||
type HermesMessageRow = {
|
||||
id: number;
|
||||
role: string;
|
||||
content: string | null;
|
||||
tool_call_id: string | null;
|
||||
tool_calls: string | null;
|
||||
tool_name: string | null;
|
||||
timestamp: number;
|
||||
reasoning: string | null;
|
||||
reasoning_content: string | null;
|
||||
finish_reason: string | null;
|
||||
};
|
||||
|
||||
function formatContent(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function readUpdateType(raw: AnyRecord): string {
|
||||
return readOptionalString(raw.type)
|
||||
?? readOptionalString(raw.kind)
|
||||
?? readOptionalString(raw.sessionUpdate)
|
||||
?? readOptionalString(raw.session_update)
|
||||
?? readOptionalString(raw.update)
|
||||
?? readOptionalString(raw.event)
|
||||
?? '';
|
||||
}
|
||||
|
||||
function readEventSessionId(raw: AnyRecord, sessionId: string | null): string | null {
|
||||
return readOptionalString(raw.sessionId) ?? readOptionalString(raw.session_id) ?? sessionId;
|
||||
}
|
||||
|
||||
function normalizeHermesEvent(rawMessage: unknown, sessionId: string | null, history = false): NormalizedMessage[] {
|
||||
const envelope = readObjectRecord(rawMessage);
|
||||
if (!envelope) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nestedUpdate = readObjectRecord(envelope.update);
|
||||
const raw = nestedUpdate ? { ...nestedUpdate, sessionId: envelope.sessionId ?? envelope.session_id ?? sessionId } : envelope;
|
||||
|
||||
const type = readUpdateType(raw);
|
||||
const eventSessionId = readEventSessionId(raw, sessionId);
|
||||
const timestamp = normalizeProviderTimestamp(raw.timestamp ?? raw.time ?? raw.createdAt ?? raw.created_at);
|
||||
const baseId = readOptionalString(raw.id) ?? readOptionalString(raw.messageId) ?? readOptionalString(raw.message_id) ?? generateMessageId(PROVIDER);
|
||||
|
||||
if (['agent_message_chunk', 'assistant_message_chunk', 'message_delta', 'text_delta', 'text'].includes(type)) {
|
||||
const content = readOptionalString(raw.content)
|
||||
?? readOptionalString(raw.text)
|
||||
?? readOptionalString(raw.delta)
|
||||
?? readOptionalString(readObjectRecord(raw.message)?.content)
|
||||
?? '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: history ? 'text' : 'stream_delta',
|
||||
role: history ? 'assistant' : undefined,
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['agent_message', 'assistant_message', 'message'].includes(type)) {
|
||||
const role = readOptionalString(raw.role) === 'user' ? 'user' : 'assistant';
|
||||
const content = readOptionalString(raw.content)
|
||||
?? readOptionalString(raw.text)
|
||||
?? readOptionalString(readObjectRecord(raw.message)?.content)
|
||||
?? '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: history ? 'text' : role === 'assistant' ? 'stream_delta' : 'text',
|
||||
role: history || role === 'user' ? role : undefined,
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['agent_thought_chunk', 'thought_delta', 'thinking', 'reasoning'].includes(type)) {
|
||||
const content = readOptionalString(raw.content) ?? readOptionalString(raw.text) ?? readOptionalString(raw.delta) ?? '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['tool_call', 'tool_use', 'tool_call_start'].includes(type)) {
|
||||
const tool = readObjectRecord(raw.tool);
|
||||
const toolId = readOptionalString(raw.toolCallId) ?? readOptionalString(raw.tool_call_id) ?? readOptionalString(raw.toolId) ?? baseId;
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: readOptionalString(raw.toolName)
|
||||
?? readOptionalString(raw.tool_name)
|
||||
?? readOptionalString(raw.title)
|
||||
?? readOptionalString(raw.name)
|
||||
?? readOptionalString(tool?.name)
|
||||
?? 'Tool',
|
||||
toolInput: raw.rawInput ?? raw.raw_input ?? raw.input ?? raw.arguments ?? raw.params ?? tool?.input ?? {},
|
||||
toolId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (['tool_call_update', 'tool_result', 'tool_call_result', 'tool_call_done'].includes(type)) {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: readOptionalString(raw.toolCallId) ?? readOptionalString(raw.tool_call_id) ?? readOptionalString(raw.toolId) ?? '',
|
||||
content: formatContent(raw.output ?? raw.result ?? raw.content ?? raw.delta ?? ''),
|
||||
isError: Boolean(raw.error) || raw.status === 'error',
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'plan') {
|
||||
const content = readOptionalString(raw.content) ?? readOptionalString(raw.text) ?? formatContent(raw.plan);
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'status',
|
||||
text: 'plan',
|
||||
summary: content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (type === 'error') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId: eventSessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: readOptionalString(raw.error) ?? readOptionalString(raw.message) ?? 'Unknown Hermes error',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseJsonArray(value: string | null): unknown[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readHermesHistoryFromDatabase(sessionId: string): NormalizedMessage[] {
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
if (!fsSync.existsSync(HERMES_DB_PATH)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const db = new Database(HERMES_DB_PATH, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, role, content, tool_call_id, tool_calls, tool_name, timestamp, reasoning, reasoning_content, finish_reason
|
||||
FROM messages
|
||||
WHERE session_id = ? AND active = 1
|
||||
ORDER BY timestamp ASC, id ASC
|
||||
`).all(sessionId) as HermesMessageRow[];
|
||||
|
||||
for (const row of rows) {
|
||||
const timestamp = new Date(row.timestamp * 1000).toISOString();
|
||||
const baseId = `hermes-${sessionId}-${row.id}`;
|
||||
|
||||
const reasoning = row.reasoning_content || row.reasoning;
|
||||
if (reasoning?.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}-thinking`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: reasoning,
|
||||
}));
|
||||
}
|
||||
|
||||
for (const toolCall of parseJsonArray(row.tool_calls)) {
|
||||
const call = readObjectRecord(toolCall);
|
||||
const fn = readObjectRecord(call?.function);
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}-tool-${readOptionalString(call?.id) ?? normalized.length}`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: readOptionalString(fn?.name) ?? readOptionalString(call?.name) ?? 'Tool',
|
||||
toolInput: fn?.arguments ?? call?.arguments ?? {},
|
||||
toolId: readOptionalString(call?.id) ?? `${baseId}-tool`,
|
||||
}));
|
||||
}
|
||||
|
||||
if (row.role === 'tool') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}-result`,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: row.tool_call_id ?? '',
|
||||
content: row.content ?? '',
|
||||
isError: row.finish_reason === 'error',
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row.content?.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: row.role === 'user' ? 'user' : 'assistant',
|
||||
content: row.content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export class HermesSessionsProvider implements IProviderSessions {
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
return normalizeHermesEvent(rawMessage, sessionId);
|
||||
}
|
||||
|
||||
async fetchHistory(sessionId: string, options: FetchHistoryOptions = {}): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
const row = sessionsDb.getSessionById(sessionId) ?? sessionsDb.getSessionByProviderSessionId(sessionId);
|
||||
const messages = readHermesHistoryFromDatabase(row?.provider_session_id ?? sessionId);
|
||||
|
||||
const start = Math.max(0, offset);
|
||||
const pageLimit = limit === null ? null : Math.max(0, limit);
|
||||
const page = sliceTailPage(messages, pageLimit, start);
|
||||
return {
|
||||
messages: page.page,
|
||||
total: messages.length,
|
||||
hasMore: page.hasMore,
|
||||
offset: start,
|
||||
limit: pageLimit,
|
||||
};
|
||||
}
|
||||
}
|
||||
181
server/modules/providers/list/hermes/hermes-skills.provider.ts
Normal file
181
server/modules/providers/list/hermes/hermes-skills.provider.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import type {
|
||||
ProviderSkillRegistryActionResult,
|
||||
ProviderSkillRegistryInstallInput,
|
||||
ProviderSkillRegistrySearchOptions,
|
||||
ProviderSkillRegistrySearchResult,
|
||||
ProviderSkillSource,
|
||||
} from '@/shared/types.js';
|
||||
import { AppError, addUniqueProviderSkillSource, readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const HERMES_COMMAND =
|
||||
(process.env.HERMES_COMMAND_PATH || process.env.HERMES_CLI_PATH || 'hermes').trim().split(/\s+/)[0] || 'hermes';
|
||||
const HERMES_SKILLS_TIMEOUT_MS = 45_000;
|
||||
const HERMES_SKILLS_MAX_BUFFER = 1024 * 1024 * 8;
|
||||
|
||||
function normalizeSearchResult(value: unknown): ProviderSkillRegistrySearchResult | null {
|
||||
const record = readObjectRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = readOptionalString(record.name);
|
||||
const identifier = readOptionalString(record.identifier);
|
||||
if (!name || !identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
identifier,
|
||||
source: readOptionalString(record.source) ?? undefined,
|
||||
trustLevel: readOptionalString(record.trust_level) ?? readOptionalString(record.trustLevel) ?? undefined,
|
||||
description: readOptionalString(record.description) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class HermesSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('hermes');
|
||||
}
|
||||
|
||||
async searchRegistry(
|
||||
query: string,
|
||||
options: ProviderSkillRegistrySearchOptions = {},
|
||||
): Promise<ProviderSkillRegistrySearchResult[]> {
|
||||
const normalizedQuery = query.trim();
|
||||
if (!normalizedQuery) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const args = ['skills', 'search', normalizedQuery, '--json'];
|
||||
const source = options.source?.trim();
|
||||
if (source) {
|
||||
args.push('--source', source);
|
||||
}
|
||||
if (options.limit && Number.isFinite(options.limit)) {
|
||||
args.push('--limit', String(Math.max(1, Math.min(Math.floor(options.limit), 50))));
|
||||
}
|
||||
|
||||
const result = await this.runHermes(args);
|
||||
try {
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.map(normalizeSearchResult).filter((entry): entry is ProviderSkillRegistrySearchResult => Boolean(entry))
|
||||
: [];
|
||||
} catch (error) {
|
||||
throw new AppError('Hermes returned invalid skill search JSON.', {
|
||||
code: 'HERMES_SKILL_SEARCH_PARSE_FAILED',
|
||||
statusCode: 502,
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async installRegistrySkill(input: ProviderSkillRegistryInstallInput): Promise<ProviderSkillRegistryActionResult> {
|
||||
const identifier = input.identifier.trim();
|
||||
if (!identifier) {
|
||||
throw new AppError('identifier is required.', {
|
||||
code: 'HERMES_SKILL_IDENTIFIER_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const args = ['skills', 'install', identifier, '--yes'];
|
||||
if (input.category?.trim()) {
|
||||
args.push('--category', input.category.trim());
|
||||
}
|
||||
if (input.name?.trim()) {
|
||||
args.push('--name', input.name.trim());
|
||||
}
|
||||
if (input.force) {
|
||||
args.push('--force');
|
||||
}
|
||||
|
||||
return this.runHermes(args);
|
||||
}
|
||||
|
||||
async uninstallRegistrySkill(name: string): Promise<ProviderSkillRegistryActionResult> {
|
||||
const normalizedName = name.trim();
|
||||
if (!normalizedName) {
|
||||
throw new AppError('name is required.', {
|
||||
code: 'HERMES_SKILL_NAME_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
return this.runHermes(['skills', 'uninstall', normalizedName]);
|
||||
}
|
||||
|
||||
async checkRegistryUpdates(): Promise<ProviderSkillRegistryActionResult> {
|
||||
return this.runHermes(['skills', 'check']);
|
||||
}
|
||||
|
||||
async updateRegistrySkills(): Promise<ProviderSkillRegistryActionResult> {
|
||||
return this.runHermes(['skills', 'update']);
|
||||
}
|
||||
|
||||
async auditRegistrySkills(): Promise<ProviderSkillRegistryActionResult> {
|
||||
return this.runHermes(['skills', 'audit']);
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
const sources: ProviderSkillSource[] = [];
|
||||
const seenRootDirs = new Set<string>();
|
||||
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'repo',
|
||||
rootDir: path.join(workspacePath, '.hermes', 'skills'),
|
||||
commandPrefix: '/',
|
||||
recursive: true,
|
||||
});
|
||||
addUniqueProviderSkillSource(sources, seenRootDirs, {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.hermes', 'skills'),
|
||||
commandPrefix: '/',
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.hermes', 'skills'),
|
||||
commandPrefix: '/',
|
||||
recursive: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async runHermes(args: string[]): Promise<ProviderSkillRegistryActionResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(HERMES_COMMAND, args, {
|
||||
timeout: HERMES_SKILLS_TIMEOUT_MS,
|
||||
maxBuffer: HERMES_SKILLS_MAX_BUFFER,
|
||||
env: process.env,
|
||||
});
|
||||
return { ok: true, stdout, stderr };
|
||||
} catch (error) {
|
||||
const maybeError = error as Error & {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
code?: number | string;
|
||||
};
|
||||
throw new AppError(maybeError.stderr || maybeError.message || 'Hermes skill command failed.', {
|
||||
code: 'HERMES_SKILL_COMMAND_FAILED',
|
||||
statusCode: 502,
|
||||
details: {
|
||||
exitCode: maybeError.code,
|
||||
stdout: maybeError.stdout,
|
||||
stderr: maybeError.stderr,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
27
server/modules/providers/list/hermes/hermes.provider.ts
Normal file
27
server/modules/providers/list/hermes/hermes.provider.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { HermesProviderAuth } from '@/modules/providers/list/hermes/hermes-auth.provider.js';
|
||||
import { HermesMcpProvider } from '@/modules/providers/list/hermes/hermes-mcp.provider.js';
|
||||
import { HermesProviderModels } from '@/modules/providers/list/hermes/hermes-models.provider.js';
|
||||
import { HermesSessionSynchronizer } from '@/modules/providers/list/hermes/hermes-session-synchronizer.provider.js';
|
||||
import { HermesSessionsProvider } from '@/modules/providers/list/hermes/hermes-sessions.provider.js';
|
||||
import { HermesSkillsProvider } from '@/modules/providers/list/hermes/hermes-skills.provider.js';
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class HermesProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new HermesProviderModels();
|
||||
readonly mcp = new HermesMcpProvider();
|
||||
readonly auth: IProviderAuth = new HermesProviderAuth();
|
||||
readonly skills: IProviderSkills = new HermesSkillsProvider();
|
||||
readonly sessions: IProviderSessions = new HermesSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new HermesSessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('hermes');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.
|
||||
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
||||
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
||||
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
||||
import { HermesProvider } from '@/modules/providers/list/hermes/hermes.provider.js';
|
||||
import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js';
|
||||
import type { IProvider } from '@/shared/interfaces.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
@@ -13,6 +14,7 @@ const providers: Record<LLMProvider, IProvider> = {
|
||||
cursor: new CursorProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
opencode: new OpenCodeProvider(),
|
||||
hermes: new HermesProvider(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -279,6 +279,48 @@ const parseProviderSkillCreatePayload = (payload: unknown): ProviderSkillCreateI
|
||||
return { entries };
|
||||
};
|
||||
|
||||
const parseSkillRegistryLimit = (value: unknown): number => {
|
||||
const raw = readOptionalQueryString(value);
|
||||
if (!raw) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new AppError('limit must be a valid integer.', {
|
||||
code: 'INVALID_QUERY_PARAMETER',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(parsed, 50));
|
||||
};
|
||||
|
||||
const parseSkillRegistryInstallPayload = (payload: unknown) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const body = payload as Record<string, unknown>;
|
||||
const identifier = readOptionalQueryString(body.identifier);
|
||||
if (!identifier) {
|
||||
throw new AppError('identifier is required.', {
|
||||
code: 'SKILL_IDENTIFIER_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
identifier,
|
||||
category: readOptionalQueryString(body.category),
|
||||
name: readOptionalQueryString(body.name),
|
||||
force: body.force === true,
|
||||
};
|
||||
};
|
||||
|
||||
const parseProvider = (value: unknown): LLMProvider => {
|
||||
const normalized = normalizeProviderParam(value);
|
||||
if (
|
||||
@@ -287,6 +329,7 @@ const parseProvider = (value: unknown): LLMProvider => {
|
||||
|| normalized === 'cursor'
|
||||
|| normalized === 'gemini'
|
||||
|| normalized === 'opencode'
|
||||
|| normalized === 'hermes'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
@@ -441,6 +484,77 @@ router.delete(
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:provider/skills/registry/search',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const query = readOptionalQueryString(req.query.query);
|
||||
if (!query) {
|
||||
throw new AppError('query is required.', {
|
||||
code: 'SKILL_SEARCH_QUERY_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const results = await providerSkillsService.searchSkillRegistry(provider, query, {
|
||||
source: readOptionalQueryString(req.query.source),
|
||||
limit: parseSkillRegistryLimit(req.query.limit),
|
||||
});
|
||||
res.json(createApiSuccessResponse({ provider, results }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills/registry/install',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.installRegistrySkill(
|
||||
provider,
|
||||
parseSkillRegistryInstallPayload(req.body),
|
||||
);
|
||||
res.status(201).json(createApiSuccessResponse({ provider, result }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills/registry/check',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.checkRegistryUpdates(provider);
|
||||
res.json(createApiSuccessResponse({ provider, result }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills/registry/update',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.updateRegistrySkills(provider);
|
||||
res.json(createApiSuccessResponse({ provider, result }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/skills/registry/audit',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.auditRegistrySkills(provider);
|
||||
res.json(createApiSuccessResponse({ provider, result }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:provider/skills/registry/:name',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const result = await providerSkillsService.uninstallRegistrySkill(
|
||||
provider,
|
||||
readPathParam(req.params.name, 'name'),
|
||||
);
|
||||
res.json(createApiSuccessResponse({ provider, result }));
|
||||
}),
|
||||
);
|
||||
|
||||
// ----------------- MCP routes -----------------
|
||||
router.get(
|
||||
'/:provider/mcp/servers',
|
||||
|
||||
@@ -75,6 +75,15 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
|
||||
supportsPermissionRequests: false,
|
||||
supportsTokenUsage: true,
|
||||
},
|
||||
hermes: {
|
||||
provider: 'hermes',
|
||||
permissionModes: ['default'],
|
||||
defaultPermissionMode: 'default',
|
||||
supportsImages: false,
|
||||
supportsAbort: true,
|
||||
supportsPermissionRequests: true,
|
||||
supportsTokenUsage: false,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ export const sessionSynchronizerService = {
|
||||
cursor: 0,
|
||||
gemini: 0,
|
||||
opencode: 0,
|
||||
hermes: 0,
|
||||
};
|
||||
const failures: string[] = [];
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> =
|
||||
provider: 'opencode',
|
||||
rootPath: path.join(os.homedir(), '.local', 'share', 'opencode'),
|
||||
},
|
||||
{
|
||||
provider: 'hermes',
|
||||
rootPath: path.join(os.homedir(), '.hermes'),
|
||||
},
|
||||
];
|
||||
|
||||
const WATCHER_IGNORED_PATTERNS = [
|
||||
@@ -81,6 +85,10 @@ function isWatcherTargetFile(provider: LLMProvider, filePath: string): boolean {
|
||||
return path.basename(filePath) === 'opencode.db';
|
||||
}
|
||||
|
||||
if (provider === 'hermes') {
|
||||
return path.basename(filePath) === 'state.db';
|
||||
}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
return filePath.endsWith('.json') || filePath.endsWith('.jsonl');
|
||||
}
|
||||
|
||||
@@ -4,7 +4,29 @@ import type {
|
||||
ProviderSkillCreateInput,
|
||||
ProviderSkillListOptions,
|
||||
ProviderSkillRemoveInput,
|
||||
ProviderSkillRegistryActionResult,
|
||||
ProviderSkillRegistryInstallInput,
|
||||
ProviderSkillRegistrySearchOptions,
|
||||
ProviderSkillRegistrySearchResult,
|
||||
} from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const getProviderSkills = (providerName: string) => providerRegistry.resolveProvider(providerName).skills;
|
||||
|
||||
const requireSkillRegistryMethod = <TMethod extends keyof ReturnType<typeof getProviderSkills>>(
|
||||
providerName: string,
|
||||
methodName: TMethod,
|
||||
): NonNullable<ReturnType<typeof getProviderSkills>[TMethod]> => {
|
||||
const skills = getProviderSkills(providerName);
|
||||
const method = skills[methodName];
|
||||
if (typeof method !== 'function') {
|
||||
throw new AppError(`${providerName} does not support skill registry operations.`, {
|
||||
code: 'PROVIDER_SKILL_REGISTRY_UNSUPPORTED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
return method as NonNullable<ReturnType<typeof getProviderSkills>[TMethod]>;
|
||||
};
|
||||
|
||||
export const providerSkillsService = {
|
||||
/**
|
||||
@@ -14,8 +36,7 @@ export const providerSkillsService = {
|
||||
providerName: string,
|
||||
options?: ProviderSkillListOptions,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.skills.listSkills(options);
|
||||
return getProviderSkills(providerName).listSkills(options);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -25,8 +46,44 @@ export const providerSkillsService = {
|
||||
providerName: string,
|
||||
input: ProviderSkillCreateInput,
|
||||
): Promise<ProviderSkill[]> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.skills.addSkills(input);
|
||||
return getProviderSkills(providerName).addSkills(input);
|
||||
},
|
||||
|
||||
async searchSkillRegistry(
|
||||
providerName: string,
|
||||
query: string,
|
||||
options?: ProviderSkillRegistrySearchOptions,
|
||||
): Promise<ProviderSkillRegistrySearchResult[]> {
|
||||
const searchRegistry = requireSkillRegistryMethod(providerName, 'searchRegistry');
|
||||
return searchRegistry.call(getProviderSkills(providerName), query, options);
|
||||
},
|
||||
|
||||
async installRegistrySkill(
|
||||
providerName: string,
|
||||
input: ProviderSkillRegistryInstallInput,
|
||||
): Promise<ProviderSkillRegistryActionResult> {
|
||||
const installRegistrySkill = requireSkillRegistryMethod(providerName, 'installRegistrySkill');
|
||||
return installRegistrySkill.call(getProviderSkills(providerName), input);
|
||||
},
|
||||
|
||||
async uninstallRegistrySkill(providerName: string, name: string): Promise<ProviderSkillRegistryActionResult> {
|
||||
const uninstallRegistrySkill = requireSkillRegistryMethod(providerName, 'uninstallRegistrySkill');
|
||||
return uninstallRegistrySkill.call(getProviderSkills(providerName), name);
|
||||
},
|
||||
|
||||
async checkRegistryUpdates(providerName: string): Promise<ProviderSkillRegistryActionResult> {
|
||||
const checkRegistryUpdates = requireSkillRegistryMethod(providerName, 'checkRegistryUpdates');
|
||||
return checkRegistryUpdates.call(getProviderSkills(providerName));
|
||||
},
|
||||
|
||||
async updateRegistrySkills(providerName: string): Promise<ProviderSkillRegistryActionResult> {
|
||||
const updateRegistrySkills = requireSkillRegistryMethod(providerName, 'updateRegistrySkills');
|
||||
return updateRegistrySkills.call(getProviderSkills(providerName));
|
||||
},
|
||||
|
||||
async auditRegistrySkills(providerName: string): Promise<ProviderSkillRegistryActionResult> {
|
||||
const auditRegistrySkills = requireSkillRegistryMethod(providerName, 'auditRegistrySkills');
|
||||
return auditRegistrySkills.call(getProviderSkills(providerName));
|
||||
},
|
||||
|
||||
async removeProviderSkill(
|
||||
|
||||
@@ -341,7 +341,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
assert.equal(globalResult.length, 5);
|
||||
assert.equal(globalResult.length, 6);
|
||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||
|
||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
@@ -356,6 +356,11 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
const opencodeProject = await readJson(path.join(workspacePath, 'opencode.json'));
|
||||
assert.ok((opencodeProject.mcp as Record<string, unknown>)['global-http']);
|
||||
|
||||
const hermesProject = await fs.readFile(path.join(workspacePath, '.hermes', 'config.yaml'), 'utf8');
|
||||
assert.match(hermesProject, /^mcp_servers:\n/m);
|
||||
assert.match(hermesProject, /^\s+global-http:\n/m);
|
||||
assert.match(hermesProject, /^\s+url: "https:\/\/global\.example\.com\/mcp"\n/m);
|
||||
|
||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
@@ -377,4 +382,3 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user