import { describe, it, afterEach } from "node:test";
import assert from "node:assert";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { loadConfig } from "../src/config.js";
import { AGENT_ROOT } from "../src/paths.js";
const TEST_CONFIG_PATH = path.join(os.tmpdir(), `hermes-memory-config-test-${process.pid}.json`);
afterEach(() => {
fs.rmSync(TEST_CONFIG_PATH, { force: true });
});
describe("loadConfig", () => {
it("returns defaults when no config file exists", () => {
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryMode, "policy-only");
assert.strictEqual(config.memoryPolicyStyle, "full");
assert.strictEqual(config.memoryPolicyCustomText, undefined);
assert.strictEqual(config.memoryCharLimit, 5000);
assert.strictEqual(config.userCharLimit, 5000);
assert.strictEqual(config.nudgeInterval, 10);
assert.strictEqual(config.reviewRecentMessages, 0);
assert.strictEqual(config.reviewEnabled, true);
assert.strictEqual(config.reviewTransport, "direct");
assert.strictEqual(config.flushOnCompact, true);
assert.strictEqual(config.flushOnShutdown, true);
assert.strictEqual(config.flushMinTurns, 6);
assert.strictEqual(config.flushRecentMessages, 0);
assert.strictEqual(config.memoryOverflowStrategy, "auto-consolidate");
assert.strictEqual(config.autoConsolidate, true);
assert.strictEqual(config.consolidationTimeoutMs, 180000);
assert.strictEqual(config.overflowGraceMs, 180000);
assert.strictEqual(config.autoConsolidationWarnOnFailure, true);
assert.strictEqual(config.failureInjectionEnabled, true);
assert.strictEqual(config.failureInjectionMaxAgeDays, 7);
assert.strictEqual(config.failureInjectionMaxEntries, 5);
assert.strictEqual(config.projectsMemoryDir, "projects-memory");
assert.deepStrictEqual(config.sessionSearch, { variant: "legacy" });
assert.strictEqual(config.llmModelOverride, undefined);
assert.strictEqual(config.llmThinkingOverride, undefined);
assert.strictEqual(config.standingInstructionsEnabled, true);
});
it("honors a configured consolidationTimeoutMs, warning only when it is below the default", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (message?: unknown) => { warnings.push(String(message)); };
try {
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ consolidationTimeoutMs: 300000 }));
assert.strictEqual(loadConfig(TEST_CONFIG_PATH).consolidationTimeoutMs, 300000);
assert.deepStrictEqual(warnings, [], "a value above the default should not warn");
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ consolidationTimeoutMs: 60000 }));
assert.strictEqual(
loadConfig(TEST_CONFIG_PATH).consolidationTimeoutMs,
60000,
"a lower configured value must be honored, not clamped",
);
assert.strictEqual(warnings.length, 1, "a sub-default value should warn once");
assert.match(warnings[0], /60000ms.*below the 180000ms default/);
} finally {
console.warn = originalWarn;
}
});
it("overrides defaults when config file exists", () => {
// Write a config file
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
memoryCharLimit: 3000,
memoryMode: "legacy-inject",
memoryPolicyStyle: "custom",
memoryPolicyCustomText: "Custom",
nudgeInterval: 15,
reviewRecentMessages: 25,
flushRecentMessages: 40,
failureInjectionEnabled: false,
failureInjectionMaxAgeDays: 30,
failureInjectionMaxEntries: 2,
projectsMemoryDir: "my-memory",
llmModelOverride: " openrouter/deepseek/deepseek-v4-flash ",
llmThinkingOverride: "minimal",
autoConsolidationWarnOnFailure: false,
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryMode, "legacy-inject");
assert.strictEqual(config.memoryPolicyStyle, "custom");
assert.strictEqual(config.memoryPolicyCustomText, "Custom");
assert.strictEqual(config.memoryCharLimit, 3000);
assert.strictEqual(config.nudgeInterval, 15);
assert.strictEqual(config.reviewRecentMessages, 25);
assert.strictEqual(config.flushRecentMessages, 40);
assert.strictEqual(config.failureInjectionEnabled, false);
assert.strictEqual(config.autoConsolidationWarnOnFailure, false);
assert.strictEqual(config.failureInjectionMaxAgeDays, 30);
assert.strictEqual(config.failureInjectionMaxEntries, 2);
assert.strictEqual(config.projectsMemoryDir, "my-memory");
assert.strictEqual(config.llmModelOverride, "openrouter/deepseek/deepseek-v4-flash");
assert.strictEqual(config.llmThinkingOverride, "minimal");
// Unset values use defaults
assert.strictEqual(config.userCharLimit, 5000);
assert.strictEqual(config.reviewEnabled, true);
});
it("handles partial config (missing keys use defaults)", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ reviewEnabled: false }));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.reviewEnabled, false);
assert.strictEqual(config.memoryMode, "policy-only");
assert.strictEqual(config.memoryPolicyStyle, "full");
assert.strictEqual(config.memoryCharLimit, 5000); // default
assert.strictEqual(config.reviewRecentMessages, 0);
assert.strictEqual(config.flushRecentMessages, 0);
assert.strictEqual(config.failureInjectionEnabled, true);
assert.strictEqual(config.failureInjectionMaxAgeDays, 7);
assert.strictEqual(config.failureInjectionMaxEntries, 5);
assert.strictEqual(config.projectsMemoryDir, "projects-memory");
assert.strictEqual(config.llmModelOverride, undefined);
assert.strictEqual(config.llmThinkingOverride, undefined);
});
it("loads, trims, and deduplicates child extension paths", () => {
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
childExtensionPaths: [" /tmp/auth-adapter.ts ", "/tmp/auth-adapter.ts", "/tmp/other-adapter.ts"],
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.deepStrictEqual(config.childExtensionPaths, ["/tmp/auth-adapter.ts", "/tmp/other-adapter.ts"]);
});
it("ignores invalid child extension path configuration", () => {
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ childExtensionPaths: ["/tmp/auth.ts", 42] }));
assert.strictEqual(loadConfig(TEST_CONFIG_PATH).childExtensionPaths, undefined);
});
it("expands ~/ memoryDir into an absolute home path", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
memoryDir: "~/.selesai/agent/pi-hermes-memory",
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryDir, path.join(os.homedir(), ".selesai", "agent", "pi-hermes-memory"));
});
it("resolves relative memoryDir values against the agent root instead of cwd", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
memoryDir: "custom-memory-root",
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryDir, path.join(AGENT_ROOT, "custom-memory-root"));
});
it("normalizes projectsMemoryDir inside the agent root and ignores unsafe values", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
projectsMemoryDir: ` ${path.join(AGENT_ROOT, "team-projects")}/ `,
}));
let config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.projectsMemoryDir, "team-projects");
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
projectsMemoryDir: "../escape",
}));
config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.projectsMemoryDir, "projects-memory");
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
projectsMemoryDir: "team/projects-memory",
}));
config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.projectsMemoryDir, "projects-memory");
});
it("handles partial config with all boolean overrides", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
reviewEnabled: false,
flushOnCompact: false,
flushOnShutdown: false,
flushMinTurns: 20,
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.reviewEnabled, false);
assert.strictEqual(config.flushOnCompact, false);
assert.strictEqual(config.flushOnShutdown, false);
assert.strictEqual(config.flushMinTurns, 20);
assert.strictEqual(config.memoryCharLimit, 5000);
assert.strictEqual(config.userCharLimit, 5000);
assert.strictEqual(config.nudgeInterval, 10);
});
it("accepts review and flush recent-message limits independently", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
reviewRecentMessages: 12,
flushRecentMessages: 34,
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.reviewRecentMessages, 12);
assert.strictEqual(config.flushRecentMessages, 34);
});
it("ignores invalid recent-message limits", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
reviewRecentMessages: -1,
flushRecentMessages: "5",
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.reviewRecentMessages, 0);
assert.strictEqual(config.flushRecentMessages, 0);
});
it("handles empty file gracefully (falls back to defaults)", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, "");
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.reviewEnabled, true);
});
it("handles malformed JSON (falls back to defaults)", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, "{ bad json }");
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryCharLimit, 5000);
assert.strictEqual(config.reviewEnabled, true);
});
it("ignores unknown keys in config file", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
unknownKey: "value",
anotherKey: 123,
memoryCharLimit: 1000,
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryCharLimit, 1000);
assert.strictEqual(config.memoryMode, "policy-only");
assert.strictEqual(config.reviewEnabled, true);
});
it("ignores invalid memoryMode values", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
memoryMode: "invalid",
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryMode, "policy-only");
});
it("accepts valid memoryPolicyStyle values", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
for (const style of ["full", "compact", "custom", "none"] as const) {
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ memoryPolicyStyle: style }));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryPolicyStyle, style);
}
});
it("ignores invalid memoryPolicyStyle values", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
memoryPolicyStyle: "invalid",
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryPolicyStyle, "full");
});
it("accepts string memoryPolicyCustomText and ignores non-string values", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
memoryPolicyCustomText: "custom policy",
}));
let config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryPolicyCustomText, "custom policy");
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
memoryPolicyCustomText: 123,
}));
config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryPolicyCustomText, undefined);
});
it("accepts valid memoryOverflowStrategy values", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
for (const policy of ["auto-consolidate", "reject", "fifo-evict"] as const) {
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ memoryOverflowStrategy: policy }));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryOverflowStrategy, policy);
assert.strictEqual(config.autoConsolidate, policy === "auto-consolidate");
}
});
it("accepts valid sessionSearch variants", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
for (const variant of ["legacy", "anchors"] as const) {
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ sessionSearch: { variant } }));
const config = loadConfig(TEST_CONFIG_PATH);
assert.deepStrictEqual(config.sessionSearch, { variant });
}
});
it("accepts valid reviewTransport values and ignores invalid ones", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
for (const transport of ["direct", "subprocess"] as const) {
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ reviewTransport: transport }));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.reviewTransport, transport);
}
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ reviewTransport: "branch" }));
const invalid = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(invalid.reviewTransport, "direct");
});
it("accepts valid llmThinkingOverride values and ignores invalid ones", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
for (const level of ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const) {
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ llmThinkingOverride: level }));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.llmThinkingOverride, level);
}
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ llmThinkingOverride: "ultra" }));
const invalid = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(invalid.llmThinkingOverride, undefined);
});
it("ignores blank llmModelOverride values", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ llmModelOverride: " " }));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.llmModelOverride, undefined);
});
it("ignores invalid sessionSearch variants", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
sessionSearch: { variant: "invalid" },
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.deepStrictEqual(config.sessionSearch, { variant: "legacy" });
});
it("ignores invalid memoryOverflowStrategy values", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
memoryOverflowStrategy: "invalid",
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryOverflowStrategy, "auto-consolidate");
assert.strictEqual(config.autoConsolidate, true);
});
it("maps legacy autoConsolidate boolean to memoryOverflowStrategy when strategy is absent", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ autoConsolidate: false }));
let config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.autoConsolidate, false);
assert.strictEqual(config.memoryOverflowStrategy, "reject");
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({ autoConsolidate: true }));
config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.autoConsolidate, true);
assert.strictEqual(config.memoryOverflowStrategy, "auto-consolidate");
});
it("lets explicit memoryOverflowStrategy override legacy autoConsolidate", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
autoConsolidate: true,
memoryOverflowStrategy: "fifo-evict",
}));
let config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryOverflowStrategy, "fifo-evict");
assert.strictEqual(config.autoConsolidate, false);
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
autoConsolidate: false,
memoryOverflowStrategy: "auto-consolidate",
}));
config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.memoryOverflowStrategy, "auto-consolidate");
assert.strictEqual(config.autoConsolidate, true);
});
it("accepts correction pattern string arrays including empty arrays", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
correctionStrongPatterns: ["^custom strong"],
correctionWeakPatterns: [],
correctionNegativePatterns: ["^custom negative"],
correctionDirectiveWords: ["shipit"],
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.deepStrictEqual(config.correctionStrongPatterns, ["^custom strong"]);
assert.deepStrictEqual(config.correctionWeakPatterns, []);
assert.deepStrictEqual(config.correctionNegativePatterns, ["^custom negative"]);
assert.deepStrictEqual(config.correctionDirectiveWords, ["shipit"]);
});
it("ignores invalid correction pattern array values", () => {
fs.mkdirSync(path.dirname(TEST_CONFIG_PATH), { recursive: true });
fs.writeFileSync(TEST_CONFIG_PATH, JSON.stringify({
correctionStrongPatterns: "^custom strong",
correctionWeakPatterns: ["^custom weak", 123],
correctionNegativePatterns: [false],
correctionDirectiveWords: { word: "shipit" },
}));
const config = loadConfig(TEST_CONFIG_PATH);
assert.strictEqual(config.correctionStrongPatterns, undefined);
assert.strictEqual(config.correctionWeakPatterns, undefined);
assert.strictEqual(config.correctionNegativePatterns, undefined);
assert.strictEqual(config.correctionDirectiveWords, undefined);
});
});