Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (props.provider === "hermes") {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
fill={mono}
d="M7.8 7.0 C9.5 6.2 11.3 6.8 11.8 8.3 C12.3 9.8 11.3 11.0 10.0 11.8 C8.7 12.6 7.9 14.0 8.6 15.6 C9.3 17.2 10.9 17.8 12.5 17.1 L12.5 15.9 C11.3 16.4 10.2 15.9 9.8 14.8 C9.3 13.6 10.1 12.7 11.3 12.0 C12.5 11.3 13.2 10.1 12.8 9.0 C12.3 7.9 11.1 7.5 9.9 7.9 Z"
/>
<Path
fill={mono}
d="M16.2 7.0 C14.5 6.2 12.7 6.8 12.2 8.3 C11.7 9.8 12.7 11.0 14.0 11.8 C15.3 12.6 16.1 14.0 15.4 15.6 C14.7 17.2 13.1 17.8 11.5 17.1 L11.5 15.9 C12.7 16.4 13.8 15.9 14.2 14.8 C14.7 13.6 13.9 12.7 12.7 12.0 C11.5 11.3 10.8 10.1 11.2 9.0 C11.7 7.9 12.9 7.5 14.1 7.9 Z"
/>
<Path
fill={mono}
d="M3.6 6.6 C4.8 5.4 6.8 5.2 8.9 6.0 L8.4 7.2 C6.7 6.7 5.2 6.9 4.3 7.8 Z"
/>
<Path
fill={mono}
d="M20.4 6.6 C19.2 5.4 17.2 5.2 15.1 6.0 L15.6 7.2 C17.3 6.7 18.8 6.9 19.7 7.8 Z"
/>
<Path fill={mono} d="M11 4.6 h2 v16.4 h-2 Z" />
<Path fill={mono} d="M12 1.9 a1.7 1.7 0 1 0 0 3.4 a1.7 1.7 0 1 0 0 -3.4 Z" />
</Svg>
);
}

if (props.provider === "cursor") {
return (
<Svg width={size} height={size} viewBox="0 0 466.73 532.09" fill="none">
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import {
* and friends) starts folded so a 300-model catalog cannot bury the list. All
* provider headers remain user-collapsible.
*/
const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex"]);
const PRIMARY_PROVIDER_DRIVERS: ReadonlySet<string> = new Set(["claudeAgent", "codex", "hermes"]);
/**
* Keep measured row changes stable, but let catalog mutations use the list's
* native bounds so a filtered catalog that underflows returns to the top.
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ import {
} from "./modelOptions";

describe("mobile model options", () => {
it("uses the Hermes product label for the built-in instance", () => {
const config = {
providers: [
{
instanceId: "hermes",
driver: "hermes",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{
slug: "default",
name: "Hermes Default",
isCustom: false,
isDefault: true,
capabilities: null,
},
],
},
],
} as unknown as ServerConfig;

expect(buildModelOptions(config, null)[0]?.providerLabel).toBe("Hermes");
});

it("groups models by provider and flags legacy entries", () => {
const config = {
providers: [
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function providerDisplayLabel(provider: {
if (provider.displayName) return provider.displayName;
if (provider.driver === "codex") return "Codex";
if (provider.driver === "claudeAgent") return "Claude";
if (provider.driver === "hermes") return "Hermes";
return provider.instanceId;
}

Expand Down
164 changes: 164 additions & 0 deletions apps/server/src/provider/Drivers/HermesDriver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { HermesSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { makeHermesTextGeneration } from "../../textGeneration/HermesTextGeneration.ts";
import { ProviderDriverError } from "../Errors.ts";
import { makeHermesAdapter } from "../Layers/HermesAdapter.ts";
import {
buildInitialHermesProviderSnapshot,
checkHermesProviderStatus,
enrichHermesSnapshot,
} from "../Layers/HermesProvider.ts";
import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
import { type ProviderDriver, type ProviderInstance } from "../ProviderDriver.ts";
import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
import {
makeProviderMaintenanceCapabilities,
resolveProviderMaintenanceCapabilitiesEffect,
type ProviderMaintenanceCapabilitiesResolver,
} from "../providerMaintenance.ts";
import {
haveProviderSnapshotSettingsChanged,
makeProviderSnapshotSettingsSource,
type ProviderSnapshotSettings,
} from "../providerUpdateSettings.ts";
import { makeHermesContinuationGroupKey, makeHermesEnvironment } from "./HermesHome.ts";
const decodeHermesSettings = Schema.decodeSync(HermesSettings);

const DRIVER_KIND = ProviderDriverKind.make("hermes");

const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
resolve: (options) =>
makeProviderMaintenanceCapabilities({
provider: DRIVER_KIND,
packageName: "hermes-agent",
updateExecutable: options?.binaryPath?.trim() || "hermes",
updateArgs: ["update"],
updateLockKey: "hermes-agent",
}),
};

export type HermesDriverEnv =
| BackgroundPolicy.BackgroundPolicy
| ChildProcessSpawner.ChildProcessSpawner
| Crypto.Crypto
| FileSystem.FileSystem
| HttpClient.HttpClient
| Path.Path
| ProviderEventLoggers
| ServerConfig
| ServerSettingsService;

const withInstanceIdentity =
(input: {
readonly instanceId: ProviderInstance["instanceId"];
readonly displayName: string | undefined;
readonly accentColor: string | undefined;
readonly continuationGroupKey: string;
}) =>
(snapshot: ServerProviderDraft): ServerProvider => ({
...snapshot,
instanceId: input.instanceId,
driver: DRIVER_KIND,
...(input.displayName ? { displayName: input.displayName } : {}),
...(input.accentColor ? { accentColor: input.accentColor } : {}),
continuation: { groupKey: input.continuationGroupKey },
});

export const HermesDriver: ProviderDriver<HermesSettings, HermesDriverEnv> = {
driverKind: DRIVER_KIND,
metadata: {
displayName: "Hermes",
supportsMultipleInstances: true,
},
configSchema: HermesSettings,
defaultConfig: (): HermesSettings => decodeHermesSettings({}),
create: ({ instanceId, displayName, accentColor, environment, enabled, config }) =>
Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const httpClient = yield* HttpClient.HttpClient;
const serverSettings = yield* ServerSettingsService;
const eventLoggers = yield* ProviderEventLoggers;
const baseEnv = mergeProviderInstanceEnvironment(environment);
const processEnv = yield* makeHermesEnvironment(config, baseEnv);
const continuationKey = yield* makeHermesContinuationGroupKey(config, baseEnv);
const continuationIdentity = { driverKind: DRIVER_KIND, continuationKey };
const stampIdentity = withInstanceIdentity({
instanceId,
displayName,
accentColor,
continuationGroupKey: continuationIdentity.continuationKey,
});
const effectiveConfig = { ...config, enabled } satisfies HermesSettings;
const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
binaryPath: effectiveConfig.binaryPath,
env: processEnv,
});

const adapter = yield* makeHermesAdapter(effectiveConfig, {
environment: processEnv,
...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
instanceId,
});
const textGeneration = yield* makeHermesTextGeneration(effectiveConfig, processEnv);

const checkProvider = checkHermesProviderStatus(effectiveConfig, processEnv).pipe(
Effect.map(stampIdentity),
Effect.provideService(Crypto.Crypto, crypto),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);

const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<HermesSettings>>({
maintenanceCapabilities,
getSettings: snapshotSettings.getSettings,
streamSettings: snapshotSettings.streamSettings,
haveSettingsChanged: haveProviderSnapshotSettingsChanged,
initialSnapshot: (settings) =>
buildInitialHermesProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
checkProvider,
enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
enrichHermesSnapshot({
snapshot: currentSnapshot,
maintenanceCapabilities,
enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
publishSnapshot,
httpClient,
}),
}).pipe(
Effect.mapError(
(cause) =>
new ProviderDriverError({
driver: DRIVER_KIND,
instanceId,
detail: "Failed to build the Hermes provider snapshot.",
cause,
}),
),
);

return {
instanceId,
driverKind: DRIVER_KIND,
continuationIdentity,
displayName,
accentColor,
enabled,
snapshot,
adapter,
textGeneration,
} satisfies ProviderInstance;
}),
};
49 changes: 49 additions & 0 deletions apps/server/src/provider/Drivers/HermesHome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as NodeOS from "node:os";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Path from "effect/Path";

import {
makeHermesContinuationGroupKey,
makeHermesEnvironment,
resolveHermesHomePath,
} from "./HermesHome.ts";

it.layer(NodeServices.layer)("HermesHome", (it) => {
describe("Hermes home resolution", () => {
it.effect("honors inherited HERMES_HOME", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const environment = { ...process.env, HERMES_HOME: "/tmp/hermes-shared" };
const resolved = path.resolve("/tmp/hermes-shared");
expect(yield* resolveHermesHomePath({ homePath: "" }, environment)).toBe(resolved);
expect(yield* makeHermesContinuationGroupKey({ homePath: "" }, environment)).toBe(
`hermes:home:${resolved}`,
);
}),
);

it.effect("lets explicit settings override the inherited home", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const resolved = path.resolve(NodeOS.homedir(), ".hermes-work");
const environment = { ...process.env, HERMES_HOME: "/tmp/hermes-shared" };
expect(
(yield* makeHermesEnvironment({ homePath: "~/.hermes-work" }, environment)).HERMES_HOME,
).toBe(resolved);
}),
);

it.effect("uses the instance HOME for Hermes' default directory", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const environment = { ...process.env, HOME: "/tmp/remote-home", HERMES_HOME: "" };
expect(yield* resolveHermesHomePath({ homePath: "" }, environment)).toBe(
path.resolve("/tmp/remote-home/.hermes"),
);
}),
);
});
});
44 changes: 44 additions & 0 deletions apps/server/src/provider/Drivers/HermesHome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as NodeOS from "node:os";

import type { HermesSettings } from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import * as Effect from "effect/Effect";
import * as Path from "effect/Path";

import { expandHomePath } from "../../pathExpansion.ts";

export const resolveHermesHomePath = Effect.fn("resolveHermesHomePath")(function* (
config: Pick<HermesSettings, "homePath">,
environment: NodeJS.ProcessEnv = process.env,
): Effect.fn.Return<string, never, Path.Path> {
const path = yield* Path.Path;
const configured = config.homePath.trim();
const inherited = environment.HERMES_HOME?.trim();
const environmentHome = environment.HOME?.trim() || environment.USERPROFILE?.trim();
const platform = yield* HostProcessPlatform;
const platformDefault =
platform === "win32" && environment.LOCALAPPDATA?.trim()
? path.join(environment.LOCALAPPDATA.trim(), "hermes")
: path.join(environmentHome || NodeOS.homedir(), ".hermes");
return path.resolve(expandHomePath(configured || inherited || platformDefault));
});

export const makeHermesEnvironment = Effect.fn("makeHermesEnvironment")(function* (
config: Pick<HermesSettings, "homePath">,
baseEnv: NodeJS.ProcessEnv = process.env,
): Effect.fn.Return<NodeJS.ProcessEnv, never, Path.Path> {
if (!config.homePath.trim()) return baseEnv;
return {
...baseEnv,
HERMES_HOME: yield* resolveHermesHomePath(config, baseEnv),
};
});

export const makeHermesContinuationGroupKey = Effect.fn("makeHermesContinuationGroupKey")(
function* (
config: Pick<HermesSettings, "homePath">,
environment?: NodeJS.ProcessEnv,
): Effect.fn.Return<string, never, Path.Path> {
return `hermes:home:${yield* resolveHermesHomePath(config, environment)}`;
},
);
4 changes: 4 additions & 0 deletions apps/server/src/provider/Layers/CursorAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,10 +863,14 @@ export function makeCursorAdapter(
turnId: ctx.activeTurnId,
...(event.itemId ? { itemId: event.itemId } : {}),
text: event.text,
streamKind: event.streamKind,
rawPayload: event.rawPayload,
}),
);
return;
case "AvailableCommandsUpdated":
case "UsageUpdated":
return;
}
}),
),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/Layers/GrokAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -865,10 +865,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte
turnId: notificationTurnId,
...(event.itemId ? { itemId: event.itemId } : {}),
text: event.text,
streamKind: event.streamKind,
rawPayload: event.rawPayload,
}),
);
return;
case "AvailableCommandsUpdated":
case "UsageUpdated":
return;
}
}),
),
Expand Down
Loading
Loading