mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
253 lines
8.1 KiB
TypeScript
253 lines
8.1 KiB
TypeScript
// Migrate Hermes plugin module implements source behavior.
|
|
import path from "node:path";
|
|
import { exists, isDirectory, readText, resolveHomePath } from "./helpers.js";
|
|
|
|
export type HermesSource = {
|
|
root: string;
|
|
configPath?: string;
|
|
envPath?: string;
|
|
authPath?: string;
|
|
globalAuthPath?: string;
|
|
opencodeAuthPath?: string;
|
|
soulPath?: string;
|
|
agentsPath?: string;
|
|
memoryPath?: string;
|
|
userPath?: string;
|
|
skillsDir?: string;
|
|
archivePaths: HermesArchivePath[];
|
|
};
|
|
|
|
type HermesArchivePath = {
|
|
id: string;
|
|
path: string;
|
|
relativePath: string;
|
|
};
|
|
|
|
const HERMES_ARCHIVE_DIRS = [
|
|
"plugins",
|
|
"sessions",
|
|
"logs",
|
|
"cron",
|
|
"mcp-tokens",
|
|
"plans",
|
|
"workspace",
|
|
"skins",
|
|
"kanban",
|
|
"pairing",
|
|
"platforms",
|
|
] as const;
|
|
const HERMES_ARCHIVE_FILES = [
|
|
"state.db",
|
|
"hermes_state.db",
|
|
"projects.db",
|
|
"response_store.db",
|
|
"memory_store.db",
|
|
"verification_evidence.db",
|
|
"kanban.db",
|
|
"retaindb_queue.db",
|
|
"gateway_state.json",
|
|
"channel_directory.json",
|
|
"channel_aliases.json",
|
|
"processes.json",
|
|
"feishu_comment_pairing.json",
|
|
] as const;
|
|
const OPENCODE_AUTH_RELATIVE_PATH = path.join(".local", "share", "opencode", "auth.json");
|
|
const HERMES_PROFILE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/u;
|
|
|
|
const HERMES_STATE_MARKERS = [
|
|
"config.yaml",
|
|
".env",
|
|
"auth.json",
|
|
"active_profile",
|
|
"SOUL.md",
|
|
"AGENTS.md",
|
|
"skills",
|
|
"memories",
|
|
...HERMES_ARCHIVE_DIRS,
|
|
...HERMES_ARCHIVE_FILES,
|
|
] as const;
|
|
|
|
function isSameOrInside(parent: string, candidate: string): boolean {
|
|
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function resolveOpenCodeXdgAuthPath(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
const xdgDataHome = env.XDG_DATA_HOME?.trim();
|
|
return xdgDataHome ? path.join(resolveHomePath(xdgDataHome), "opencode", "auth.json") : undefined;
|
|
}
|
|
|
|
async function discoverOpenCodeAuthPath(params: {
|
|
root: string;
|
|
includeGlobalFallback: boolean;
|
|
includeHomeFallback: boolean;
|
|
env: NodeJS.ProcessEnv;
|
|
}): Promise<string | undefined> {
|
|
const rootParent = path.dirname(params.root);
|
|
const xdgAuthPath = resolveOpenCodeXdgAuthPath(params.env);
|
|
const candidates = Array.from(
|
|
new Set(
|
|
[
|
|
...(xdgAuthPath && (params.includeGlobalFallback || isSameOrInside(rootParent, xdgAuthPath))
|
|
? [xdgAuthPath]
|
|
: []),
|
|
path.join(rootParent, OPENCODE_AUTH_RELATIVE_PATH),
|
|
...(params.includeHomeFallback
|
|
? [
|
|
path.join(
|
|
path.resolve(
|
|
params.env.HOME?.trim() || params.env.USERPROFILE?.trim() || resolveHomePath("~"),
|
|
),
|
|
OPENCODE_AUTH_RELATIVE_PATH,
|
|
),
|
|
]
|
|
: []),
|
|
].filter((candidate): candidate is string => Boolean(candidate)),
|
|
),
|
|
);
|
|
for (const candidate of candidates) {
|
|
if (await exists(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export async function discoverHermesSource(
|
|
input?: string,
|
|
options: {
|
|
env?: NodeJS.ProcessEnv;
|
|
platform?: NodeJS.Platform;
|
|
} = {},
|
|
): Promise<HermesSource> {
|
|
const env = options.env ?? process.env;
|
|
const platform = options.platform ?? process.platform;
|
|
const explicitInput = input?.trim();
|
|
const root = explicitInput
|
|
? resolveHomePath(explicitInput)
|
|
: await resolveImplicitHermesRoot(env, platform);
|
|
const opencodeAuthPath = await discoverOpenCodeAuthPath({
|
|
root,
|
|
includeGlobalFallback: !explicitInput,
|
|
includeHomeFallback: !explicitInput,
|
|
env,
|
|
});
|
|
const profileParent = path.dirname(root);
|
|
const globalRoot =
|
|
!explicitInput && path.basename(profileParent) === "profiles"
|
|
? path.dirname(profileParent)
|
|
: undefined;
|
|
const globalAuthPath = globalRoot ? path.join(globalRoot, "auth.json") : undefined;
|
|
const archivePaths: HermesArchivePath[] = [];
|
|
for (const dir of HERMES_ARCHIVE_DIRS) {
|
|
const candidate = path.join(root, dir);
|
|
if (await isDirectory(candidate)) {
|
|
archivePaths.push({ id: `archive:${dir}`, path: candidate, relativePath: dir });
|
|
}
|
|
}
|
|
for (const file of HERMES_ARCHIVE_FILES) {
|
|
const candidate = path.join(root, file);
|
|
if (await exists(candidate)) {
|
|
archivePaths.push({ id: `archive:${file}`, path: candidate, relativePath: file });
|
|
}
|
|
}
|
|
return {
|
|
root,
|
|
archivePaths,
|
|
...((await exists(path.join(root, "config.yaml")))
|
|
? { configPath: path.join(root, "config.yaml") }
|
|
: {}),
|
|
...((await exists(path.join(root, ".env"))) ? { envPath: path.join(root, ".env") } : {}),
|
|
...((await exists(path.join(root, "auth.json")))
|
|
? { authPath: path.join(root, "auth.json") }
|
|
: {}),
|
|
...(globalAuthPath && (await exists(globalAuthPath)) ? { globalAuthPath } : {}),
|
|
...(opencodeAuthPath ? { opencodeAuthPath } : {}),
|
|
...((await exists(path.join(root, "SOUL.md"))) ? { soulPath: path.join(root, "SOUL.md") } : {}),
|
|
...((await exists(path.join(root, "AGENTS.md")))
|
|
? { agentsPath: path.join(root, "AGENTS.md") }
|
|
: {}),
|
|
...((await exists(path.join(root, "memories", "MEMORY.md")))
|
|
? { memoryPath: path.join(root, "memories", "MEMORY.md") }
|
|
: {}),
|
|
...((await exists(path.join(root, "memories", "USER.md")))
|
|
? { userPath: path.join(root, "memories", "USER.md") }
|
|
: {}),
|
|
...((await isDirectory(path.join(root, "skills")))
|
|
? { skillsDir: path.join(root, "skills") }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async function resolveImplicitHermesRoot(
|
|
env: NodeJS.ProcessEnv,
|
|
platform: NodeJS.Platform,
|
|
): Promise<string> {
|
|
// Hermes pins the reserved supervised default slot to the root profile and
|
|
// never follows active_profile there (hermes_cli/main.py:487). Mirror that so
|
|
// a migration launched from that process keeps the default profile.
|
|
const supervisedChild = Boolean(env.HERMES_S6_SUPERVISED_CHILD?.trim());
|
|
const configuredHome = env.HERMES_HOME?.trim();
|
|
if (configuredHome) {
|
|
const configuredRoot = resolveHomePath(configuredHome);
|
|
// Mirror Hermes itself (hermes-agent hermes_cli/main.py:461-473, issue #22502):
|
|
// trust HERMES_HOME verbatim only when it already names a profile dir
|
|
// (parent basename `profiles`); when it names the root (e.g. a hardcoded
|
|
// HERMES_HOME=~/.hermes) still honor active_profile.
|
|
if (supervisedChild || path.basename(path.dirname(configuredRoot)) === "profiles") {
|
|
return configuredRoot;
|
|
}
|
|
return await resolveActiveHermesProfile(configuredRoot);
|
|
}
|
|
const userHome =
|
|
(platform === "win32" ? env.USERPROFILE?.trim() : env.HOME?.trim()) || resolveHomePath("~");
|
|
let root: string;
|
|
if (platform === "win32") {
|
|
// Hermes stores both active_profile and profiles below LOCALAPPDATA on Windows.
|
|
const localAppData = env.LOCALAPPDATA?.trim() || path.join(userHome, "AppData", "Local");
|
|
const platformRoot = path.resolve(localAppData, "hermes");
|
|
const legacyRoot = path.resolve(userHome, ".hermes");
|
|
root = (await hasHermesState(platformRoot))
|
|
? platformRoot
|
|
: (await hasHermesState(legacyRoot))
|
|
? legacyRoot
|
|
: platformRoot;
|
|
} else {
|
|
root = path.resolve(userHome, ".hermes");
|
|
}
|
|
return supervisedChild ? root : await resolveActiveHermesProfile(root);
|
|
}
|
|
|
|
async function resolveActiveHermesProfile(root: string): Promise<string> {
|
|
const activeProfile = (await readText(path.join(root, "active_profile")))?.trim();
|
|
if (!activeProfile || activeProfile === "default" || !HERMES_PROFILE_RE.test(activeProfile)) {
|
|
return root;
|
|
}
|
|
const profileRoot = path.join(root, "profiles", activeProfile);
|
|
return (await isDirectory(profileRoot)) ? profileRoot : root;
|
|
}
|
|
|
|
async function hasHermesState(root: string): Promise<boolean> {
|
|
for (const marker of HERMES_STATE_MARKERS) {
|
|
if (await exists(path.join(root, marker))) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function hasHermesSource(source: HermesSource): boolean {
|
|
return Boolean(
|
|
source.configPath ||
|
|
source.envPath ||
|
|
source.authPath ||
|
|
source.globalAuthPath ||
|
|
source.soulPath ||
|
|
source.agentsPath ||
|
|
source.memoryPath ||
|
|
source.userPath ||
|
|
source.skillsDir ||
|
|
source.archivePaths.length > 0,
|
|
);
|
|
}
|