Skip to main content
reference

FilePersonalityRegistry reference

FilePersonalityRegistry is the disk-backed loader for personalities. It walks one or more directories of <id>/{SOUL.md, config.yaml, toolset.yaml} triples, parses them into PersonalityConfig values, and caches based on file mtimes so loadFromDirectory is cheap to call every turn for hot-reload.

Source​

extensions/personalities/src/index.ts. Ships as @ethosagent/personalities.

FilePersonalityRegistry​

Signature​

import { FilePersonalityRegistry, createPersonalityRegistry } from '@ethosagent/personalities';
import { FsStorage } from '@ethosagent/storage-fs';

const registry = new FilePersonalityRegistry(new FsStorage(), '/home/me/.ethos');
await registry.loadBuiltins();
await registry.loadFromDirectory('/home/me/.ethos/personalities');

Constructor​

constructor(storage?: Storage, userPersonalitiesDir?: string)
ParamDefaultDescription
storagenew FsStorage()The Storage backend. Tests pass InMemoryStorage.
userPersonalitiesDirundefinedRoot containing a personalities/ subdir for user-mutable personalities. When unset, CRUD methods throw.

Convenience factory:

const registry = await createPersonalityRegistry({
storage: new FsStorage(),
userPersonalitiesDir: '/home/me/.ethos',
});

Read methods​

MethodReturnsDescription
define(config)voidInsert / replace by id. Used by plugin-registered personalities and tests.
get(id)PersonalityConfig | undefinedLook up by id.
list()PersonalityConfig[]Every loaded personality.
getDefault()PersonalityConfigDefault personality (initially researcher if loaded; otherwise the first loaded).
setDefault(id)voidSet the default. Throws if id is not loaded.
describe(id)DescribedPersonality | nullReturns { config, builtin }. builtin === true when the source dir is the package's bundled data/.
describeAll()DescribedPersonality[]Same as describe for every loaded id.
readSoulMd(id)Promise<string>Read the personality's SOUL.md body. Returns '' if absent.
userPathFor(id)stringAbsolute path of <userPersonalitiesDir>/<id> (even if it does not exist). Throws if no userPersonalitiesDir was configured.

Loaders​

MethodDescription
loadBuiltins()Walk the package's bundled data/ directory (resolved via import.meta.dirname). Sets default = researcher if present.
loadFromDirectory(dir)Walk dir/* and load each subdirectory as a personality. Mtime-cached — re-reading is cheap when nothing changed.

CRUD methods​

Available only when userPersonalitiesDir was passed to the constructor. Built-ins are read-only; clone with duplicate(id, newId) (table below) first.

MethodDescription
create(input)Write a new <userDir>/personalities/<id>/ with config.yaml, toolset.yaml, SOUL.md. Throws PERSONALITY_EXISTS if the id is taken.
update(id, patch)Patch one or more fields. Only the fields present in patch are rewritten. Throws PERSONALITY_READ_ONLY for built-ins.
duplicate(id, newId)Copy a built-in (or any other) personality into the user dir. The copy's name: becomes <original> (copy).
deletePersonality(id)rm -rf the personality's user dir and drop from memory. Throws for built-ins.
remove(id)In-memory drop only — does not touch disk. Used internally; rarely called directly.

CreatePersonalityInput​

export interface CreatePersonalityInput {
id: string;
name: string;
description?: string;
model?: string | ModelTierConfig;
toolset: string[];
soulMd: string;
provider?: string;
capabilities?: string[];
mcp_servers?: string[];
plugins?: string[];
fs_reach?: { read?: string[]; write?: string[]; workdir?: string | string[] };
skill_evolution?: {
enabled?: boolean;
min_tool_calls?: number;
cooldown_minutes?: number;
model?: string;
evolve_existing?: boolean;
promotion?: 'review' | 'auto';
scope?: 'personality' | 'shared';
};
dreaming?: DreamingConfig;
evolution_approval_mode?: 'auto' | 'user';
nightly?: PersonalityConfig['nightly'];
voice?: EditableVoiceConfig;
safety?: Pick<NonNullable<PersonalityConfig['safety']>, 'network'>;
}

UpdatePersonalityPatch​

export interface UpdatePersonalityPatch {
name?: string;
description?: string;
model?: string | ModelTierConfig;
toolset?: string[];
soulMd?: string;
mcp_servers?: string[];
plugins?: string[];
capabilities?: string[];
provider?: string;
fs_reach?: { read?: string[]; write?: string[]; workdir?: string | string[] };
dreaming?: Partial<DreamingConfig>;
dreamingEnable?: boolean;
evolution_approval_mode?: 'auto' | 'user';
skill_evolution?: PersonalityConfig['skill_evolution'];
safety?: PersonalityConfig['safety'];
memory?: PersonalityConfig['memory'];
nightly?: PersonalityConfig['nightly'];
voice?: EditableVoiceConfig;
display?: { avatar_url?: string };
}

Neither shape has a memory scope field: a personality's memory scope is always personality:<id>. A model names a role or a modelRegistry alias (parseModelDeclaration (packages/types/src/model-registry.ts)), as one string or as a per-role map written back as model.<role> keys; with no registry configured, resolveTurnModel (packages/core/src/agent-loop/turn-model.ts) does not read it at all. voice.* and display.avatar_url treat '' as clear and undefined as leave-alone.

mtime caching​

loadOne() fingerprints each personality dir from the mtimes of six paths: config.yaml, SOUL.md, toolset.yaml, mcp.yaml, tools.yaml and the skills/ directory (fileFingerprint). The skills/ entry is the directory's own mtime, so adding or removing a skill file reloads the personality and editing one in place does not.

Cache stored in fingerprintCache: Map<dir, fingerprint>. If the recomputed fingerprint matches the cached value, the load is a no-op. Hot-reload at turn-start is therefore cheap when nothing changed.

Filesystemmtime resolution
APFSnanosecond
ext4nanosecond (since 2.6.11)
NTFS100ns

Sub-millisecond resolution makes two writes within the same tick vanishingly unlikely for personality files (human-paced edits, not log streams).

YAML parsing​

config.yaml is parsed by a minimal in-package parser — no external YAML dependency.

PatternBehaviour
key: valueFlat key/value. Quotes stripped.
key1.key2: valueDotted key (e.g. fs_reach.read). Used for nested config that fits the flat parser.
safety: blockRecognised top-level nested block (allowlisted). Two levels deep.
Any other nested blockThrows — top-level non-safety nested objects are rejected.
key:\n - a\n - bList value inside a nested block.

toolset.yaml is a flat list:

- read_file
- write_file
- web_search

Notes​

  • describe(id).builtin is computed by comparing config.soulFile against the user-dir prefix. Personalities loaded from the package's data/ directory always report builtin: true.
  • loadBuiltins() resolves the data directory via import.meta.dirname (Node 21.2+). Do not replace with the fileURLToPath(new URL(...)) workaround — Ethos runs on Node 24.
  • validateUnsafeCombinations refuses load if a personality has safety.approvalMode: off and platform: is one of the channel-ingress platforms (telegram, discord, slack, whatsapp, email). Stranger-driven auto-approval is rejected at config-load time.
  • The defaultId field is in-memory only; restart resets it to researcher (or first loaded).

Used by​

ConsumerRole
apps/ethos/src/wiring.tsInstantiates the registry once at startup.
apps/ethos/src/commands/personality.tsPowers ethos personality list/set/duplicate.
apps/ethos/src/commands/chat.ts/personality <id> looks up via get and setDefault.
apps/web/src/api/personalities.tsWeb API routes for personality CRUD.
packages/core/src/agent-loop.tsReads personality.toolset, personality.fs_reach, personality.plugins, personality.mcp_servers each turn.

See also​