Compare commits

...

4 Commits

Author SHA1 Message Date
Dewaldt Huysamen 2b7c1751f1
Merge f90b98a02c into 2f7e548a57 2026-03-14 23:48:12 +01:00
GodsBoy f90b98a02c fix: cast AgentModelConfig in tests to resolve TS2339 on .primary 2026-03-07 07:29:40 +02:00
Dewaldt Huysamen c632308dcc fix: guard multiselect return against cancel (null/undefined) 2026-03-07 07:25:17 +02:00
Dewaldt Huysamen f6ea75ed9f feat(onboarding): add Memory Optimization step to onboarding wizard 2026-03-07 07:25:17 +02:00
4 changed files with 622 additions and 0 deletions

View File

@ -0,0 +1,439 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { applyNonInteractiveMemoryDefaults, setupMemoryOptimization } from "./onboard-memory.js";
describe("onboard-memory", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockPrompter = (multiselectValue: string[]): WizardPrompter => ({
confirm: vi.fn().mockResolvedValue(true),
note: vi.fn().mockResolvedValue(undefined),
intro: vi.fn().mockResolvedValue(undefined),
outro: vi.fn().mockResolvedValue(undefined),
text: vi.fn().mockResolvedValue(""),
select: vi.fn().mockResolvedValue(""),
multiselect: vi.fn().mockResolvedValue(multiselectValue),
progress: vi.fn().mockReturnValue({
stop: vi.fn(),
update: vi.fn(),
}),
});
const createMockRuntime = (): RuntimeEnv => ({
log: vi.fn(),
error: vi.fn(),
exit: vi.fn() as unknown as RuntimeEnv["exit"],
});
describe("setupMemoryOptimization", () => {
it("should enable all options when all are selected", async () => {
const cfg: OpenClawConfig = {};
const prompter = createMockPrompter([
"hybrid-search",
"embedding-cache",
"memory-flush",
"session-transcripts",
]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
// Hybrid search
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.enabled).toBe(true);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.vectorWeight).toBe(0.7);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.textWeight).toBe(0.3);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.candidateMultiplier).toBe(4);
// Embedding cache
expect(result.agents?.defaults?.memorySearch?.cache?.enabled).toBe(true);
expect(result.agents?.defaults?.memorySearch?.cache?.maxEntries).toBe(50_000);
// Memory flush
expect(result.agents?.defaults?.compaction?.mode).toBe("safeguard");
expect(result.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(true);
// Session transcripts
expect(result.agents?.defaults?.memorySearch?.enabled).toBe(true);
expect(result.agents?.defaults?.memorySearch?.experimental?.sessionMemory).toBe(true);
expect(result.agents?.defaults?.memorySearch?.sync?.sessions?.deltaBytes).toBe(50_000);
expect(result.agents?.defaults?.memorySearch?.sync?.sessions?.deltaMessages).toBe(25);
});
it("should enable only hybrid search when selected alone", async () => {
const cfg: OpenClawConfig = {};
const prompter = createMockPrompter(["hybrid-search"]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.enabled).toBe(true);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.vectorWeight).toBe(0.7);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.textWeight).toBe(0.3);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.candidateMultiplier).toBe(4);
// Other options should not be set
expect(result.agents?.defaults?.memorySearch?.cache).toBeUndefined();
expect(result.agents?.defaults?.compaction).toBeUndefined();
expect(result.agents?.defaults?.memorySearch?.experimental).toBeUndefined();
});
it("should enable only embedding cache when selected alone", async () => {
const cfg: OpenClawConfig = {};
const prompter = createMockPrompter(["embedding-cache"]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
expect(result.agents?.defaults?.memorySearch?.cache?.enabled).toBe(true);
expect(result.agents?.defaults?.memorySearch?.cache?.maxEntries).toBe(50_000);
// Other options should not be set
expect(result.agents?.defaults?.memorySearch?.query).toBeUndefined();
expect(result.agents?.defaults?.compaction).toBeUndefined();
expect(result.agents?.defaults?.memorySearch?.experimental).toBeUndefined();
});
it("should enable only memory flush when selected alone", async () => {
const cfg: OpenClawConfig = {};
const prompter = createMockPrompter(["memory-flush"]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
expect(result.agents?.defaults?.compaction?.mode).toBe("safeguard");
expect(result.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(true);
// Other options should not be set
expect(result.agents?.defaults?.memorySearch).toBeUndefined();
});
it("should enable only session transcripts when selected alone", async () => {
const cfg: OpenClawConfig = {};
const prompter = createMockPrompter(["session-transcripts"]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
expect(result.agents?.defaults?.memorySearch?.enabled).toBe(true);
expect(result.agents?.defaults?.memorySearch?.experimental?.sessionMemory).toBe(true);
expect(result.agents?.defaults?.memorySearch?.sync?.sessions?.deltaBytes).toBe(50_000);
expect(result.agents?.defaults?.memorySearch?.sync?.sessions?.deltaMessages).toBe(25);
// Other options should not be set
expect(result.agents?.defaults?.memorySearch?.query).toBeUndefined();
expect(result.agents?.defaults?.memorySearch?.cache).toBeUndefined();
expect(result.agents?.defaults?.compaction).toBeUndefined();
});
it("should return config unchanged when user skips", async () => {
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: "/my-workspace" } },
};
const prompter = createMockPrompter(["__skip__"]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
expect(result).toEqual(cfg);
// Only the intro note should be shown, no confirmation note
expect(prompter.note).toHaveBeenCalledTimes(1);
});
it("should NOT overwrite existing hybrid search config values", async () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
memorySearch: {
query: {
hybrid: {
enabled: true,
vectorWeight: 0.5,
textWeight: 0.5,
candidateMultiplier: 8,
},
},
},
},
},
};
const prompter = createMockPrompter(["hybrid-search"]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
// Existing values must be preserved (nullish coalescing)
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.vectorWeight).toBe(0.5);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.textWeight).toBe(0.5);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.candidateMultiplier).toBe(8);
});
it("should NOT overwrite existing embedding cache config values", async () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
memorySearch: {
cache: {
enabled: false,
maxEntries: 10_000,
},
},
},
},
};
const prompter = createMockPrompter(["embedding-cache"]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
expect(result.agents?.defaults?.memorySearch?.cache?.enabled).toBe(false);
expect(result.agents?.defaults?.memorySearch?.cache?.maxEntries).toBe(10_000);
});
it("should NOT overwrite existing compaction config values", async () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
compaction: {
mode: "default",
memoryFlush: {
enabled: false,
},
},
},
},
};
const prompter = createMockPrompter(["memory-flush"]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
// Existing values must be preserved
expect(result.agents?.defaults?.compaction?.mode).toBe("default");
expect(result.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(false);
});
it("should NOT overwrite existing session transcript config values", async () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
memorySearch: {
enabled: false,
experimental: {
sessionMemory: false,
},
sync: {
sessions: {
deltaBytes: 100_000,
deltaMessages: 50,
},
},
},
},
},
};
const prompter = createMockPrompter(["session-transcripts"]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
expect(result.agents?.defaults?.memorySearch?.enabled).toBe(false);
expect(result.agents?.defaults?.memorySearch?.experimental?.sessionMemory).toBe(false);
expect(result.agents?.defaults?.memorySearch?.sync?.sessions?.deltaBytes).toBe(100_000);
expect(result.agents?.defaults?.memorySearch?.sync?.sessions?.deltaMessages).toBe(50);
});
it("should preserve unrelated existing config", async () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: "/existing-workspace",
model: { primary: "anthropic/claude-opus-4-5" },
},
},
gateway: { mode: "local", port: 3000 },
};
const prompter = createMockPrompter(["hybrid-search"]);
const runtime = createMockRuntime();
const result = await setupMemoryOptimization(cfg, runtime, prompter);
expect(result.agents?.defaults?.workspace).toBe("/existing-workspace");
expect((result.agents?.defaults?.model as { primary?: string })?.primary).toBe(
"anthropic/claude-opus-4-5",
);
expect(result.gateway?.mode).toBe("local");
expect(result.gateway?.port).toBe(3000);
// And the new config should still be applied
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.enabled).toBe(true);
});
it("should show correct multiselect options", async () => {
const cfg: OpenClawConfig = {};
const prompter = createMockPrompter(["__skip__"]);
const runtime = createMockRuntime();
await setupMemoryOptimization(cfg, runtime, prompter);
expect(prompter.multiselect).toHaveBeenCalledWith({
message: "Enable memory optimizations?",
options: [
{ value: "__skip__", label: "Skip for now" },
{
value: "hybrid-search",
label: "🔍 Hybrid search (BM25 + vector)",
hint: "70/30 vector/text blend with 4x candidate pool — improves recall for exact terms",
},
{
value: "embedding-cache",
label: "💾 Embedding cache",
hint: "Caches embeddings in SQLite — saves API calls on reindex",
},
{
value: "memory-flush",
label: "🧠 Pre-compaction memory flush",
hint: "Auto-saves notes before context compaction — prevents amnesia",
},
{
value: "session-transcripts",
label: "📜 Session transcript search",
hint: "Indexes past transcripts via memory_search (experimental)",
},
],
});
});
it("should show intro and confirmation notes", async () => {
const cfg: OpenClawConfig = {};
const prompter = createMockPrompter(["hybrid-search", "memory-flush"]);
const runtime = createMockRuntime();
await setupMemoryOptimization(cfg, runtime, prompter);
const noteCalls = (prompter.note as ReturnType<typeof vi.fn>).mock.calls;
expect(noteCalls).toHaveLength(2);
// Intro note
expect(noteCalls[0][0]).toContain("Memory optimization");
expect(noteCalls[0][1]).toBe("Memory Optimization");
// Confirmation note
expect(noteCalls[1][0]).toContain("Enabled 2 optimizations: hybrid search, memory flush");
expect(noteCalls[1][1]).toBe("Memory Configured");
});
it("should not mutate the original config", async () => {
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: "/ws" } },
};
const original = JSON.stringify(cfg);
const prompter = createMockPrompter(["hybrid-search"]);
const runtime = createMockRuntime();
await setupMemoryOptimization(cfg, runtime, prompter);
expect(JSON.stringify(cfg)).toBe(original);
});
});
describe("applyNonInteractiveMemoryDefaults", () => {
it("should enable hybrid search, embedding cache, and memory flush", () => {
const cfg: OpenClawConfig = {};
const result = applyNonInteractiveMemoryDefaults(cfg);
// Hybrid search
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.enabled).toBe(true);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.vectorWeight).toBe(0.7);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.textWeight).toBe(0.3);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.candidateMultiplier).toBe(4);
// Embedding cache
expect(result.agents?.defaults?.memorySearch?.cache?.enabled).toBe(true);
expect(result.agents?.defaults?.memorySearch?.cache?.maxEntries).toBe(50_000);
// Memory flush
expect(result.agents?.defaults?.compaction?.mode).toBe("safeguard");
expect(result.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(true);
});
it("should NOT enable session transcript search (experimental, opt-in only)", () => {
const cfg: OpenClawConfig = {};
const result = applyNonInteractiveMemoryDefaults(cfg);
expect(result.agents?.defaults?.memorySearch?.experimental?.sessionMemory).toBeUndefined();
expect(result.agents?.defaults?.memorySearch?.sync?.sessions).toBeUndefined();
});
it("should NOT overwrite existing values", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
memorySearch: {
query: {
hybrid: {
enabled: false,
vectorWeight: 0.9,
},
},
cache: {
enabled: false,
},
},
compaction: {
mode: "default",
memoryFlush: {
enabled: false,
},
},
},
},
};
const result = applyNonInteractiveMemoryDefaults(cfg);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.enabled).toBe(false);
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.vectorWeight).toBe(0.9);
// textWeight was not set, so it should get the default
expect(result.agents?.defaults?.memorySearch?.query?.hybrid?.textWeight).toBe(0.3);
expect(result.agents?.defaults?.memorySearch?.cache?.enabled).toBe(false);
expect(result.agents?.defaults?.compaction?.mode).toBe("default");
expect(result.agents?.defaults?.compaction?.memoryFlush?.enabled).toBe(false);
});
it("should not mutate the original config", () => {
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: "/ws" } },
};
const original = JSON.stringify(cfg);
applyNonInteractiveMemoryDefaults(cfg);
expect(JSON.stringify(cfg)).toBe(original);
});
it("should preserve unrelated config", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: "/existing",
model: { primary: "openai/gpt-4o" },
},
},
gateway: { port: 4000 },
};
const result = applyNonInteractiveMemoryDefaults(cfg);
expect(result.agents?.defaults?.workspace).toBe("/existing");
expect((result.agents?.defaults?.model as { primary?: string })?.primary).toBe(
"openai/gpt-4o",
);
expect(result.gateway?.port).toBe(4000);
});
});
});

View File

@ -0,0 +1,176 @@
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
const MEMORY_OPTIONS = {
hybridSearch: "hybrid-search",
embeddingCache: "embedding-cache",
memoryFlush: "memory-flush",
sessionTranscripts: "session-transcripts",
} as const;
export async function setupMemoryOptimization(
cfg: OpenClawConfig,
_runtime: RuntimeEnv,
prompter: WizardPrompter,
): Promise<OpenClawConfig> {
await prompter.note(
[
"Memory optimization surfaces powerful but non-obvious features",
"that dramatically improve recall, caching, and context persistence.",
"",
"All options use safe defaults and never overwrite your existing config.",
].join("\n"),
"Memory Optimization",
);
const selected = await prompter.multiselect({
message: "Enable memory optimizations?",
options: [
{ value: "__skip__", label: "Skip for now" },
{
value: MEMORY_OPTIONS.hybridSearch,
label: "🔍 Hybrid search (BM25 + vector)",
hint: "70/30 vector/text blend with 4x candidate pool — improves recall for exact terms",
},
{
value: MEMORY_OPTIONS.embeddingCache,
label: "💾 Embedding cache",
hint: "Caches embeddings in SQLite — saves API calls on reindex",
},
{
value: MEMORY_OPTIONS.memoryFlush,
label: "🧠 Pre-compaction memory flush",
hint: "Auto-saves notes before context compaction — prevents amnesia",
},
{
value: MEMORY_OPTIONS.sessionTranscripts,
label: "📜 Session transcript search",
hint: "Indexes past transcripts via memory_search (experimental)",
},
],
});
const choices = new Set((selected ?? []).filter((v) => v !== "__skip__"));
if (choices.size === 0) {
return cfg;
}
let next = structuredClone(cfg);
if (choices.has(MEMORY_OPTIONS.hybridSearch)) {
next = applyHybridSearch(next);
}
if (choices.has(MEMORY_OPTIONS.embeddingCache)) {
next = applyEmbeddingCache(next);
}
if (choices.has(MEMORY_OPTIONS.memoryFlush)) {
next = applyMemoryFlush(next);
}
if (choices.has(MEMORY_OPTIONS.sessionTranscripts)) {
next = applySessionTranscripts(next);
}
const labels: string[] = [];
if (choices.has(MEMORY_OPTIONS.hybridSearch)) {
labels.push("hybrid search");
}
if (choices.has(MEMORY_OPTIONS.embeddingCache)) {
labels.push("embedding cache");
}
if (choices.has(MEMORY_OPTIONS.memoryFlush)) {
labels.push("memory flush");
}
if (choices.has(MEMORY_OPTIONS.sessionTranscripts)) {
labels.push("session transcripts");
}
await prompter.note(
[
`Enabled ${labels.length} optimization${labels.length > 1 ? "s" : ""}: ${labels.join(", ")}`,
"",
"You can tune these later in your config under:",
" agents.defaults.memorySearch",
" agents.defaults.compaction",
].join("\n"),
"Memory Configured",
);
return next;
}
// ── Helpers (safe deep-set with nullish coalescing) ──────────────────
function ensureAgentsDefaults(cfg: OpenClawConfig): OpenClawConfig {
cfg.agents ??= {};
cfg.agents.defaults ??= {};
return cfg;
}
function ensureMemorySearch(cfg: OpenClawConfig): OpenClawConfig {
cfg = ensureAgentsDefaults(cfg);
cfg.agents!.defaults!.memorySearch ??= {};
return cfg;
}
function applyHybridSearch(cfg: OpenClawConfig): OpenClawConfig {
cfg = ensureMemorySearch(cfg);
const ms = cfg.agents!.defaults!.memorySearch!;
ms.query ??= {};
ms.query.hybrid ??= {};
ms.query.hybrid.enabled ??= true;
ms.query.hybrid.vectorWeight ??= 0.7;
ms.query.hybrid.textWeight ??= 0.3;
ms.query.hybrid.candidateMultiplier ??= 4;
return cfg;
}
function applyEmbeddingCache(cfg: OpenClawConfig): OpenClawConfig {
cfg = ensureMemorySearch(cfg);
const ms = cfg.agents!.defaults!.memorySearch!;
ms.cache ??= {};
ms.cache.enabled ??= true;
ms.cache.maxEntries ??= 50_000;
return cfg;
}
function applyMemoryFlush(cfg: OpenClawConfig): OpenClawConfig {
cfg = ensureAgentsDefaults(cfg);
const d = cfg.agents!.defaults!;
d.compaction ??= {};
d.compaction.mode ??= "safeguard";
d.compaction.memoryFlush ??= {};
d.compaction.memoryFlush.enabled ??= true;
return cfg;
}
function applySessionTranscripts(cfg: OpenClawConfig): OpenClawConfig {
cfg = ensureMemorySearch(cfg);
const ms = cfg.agents!.defaults!.memorySearch!;
ms.enabled ??= true;
ms.experimental ??= {};
ms.experimental.sessionMemory ??= true;
ms.sync ??= {};
ms.sync.sessions ??= {};
ms.sync.sessions.deltaBytes ??= 50_000;
ms.sync.sessions.deltaMessages ??= 25;
return cfg;
}
// ── Non-interactive defaults ─────────────────────────────────────────
/**
* Apply sensible memory optimization defaults for non-interactive onboarding.
* Enables hybrid search, embedding cache, and pre-compaction memory flush.
* Session transcript search is experimental and opt-in only.
*/
export function applyNonInteractiveMemoryDefaults(cfg: OpenClawConfig): OpenClawConfig {
let next = structuredClone(cfg);
next = applyHybridSearch(next);
next = applyEmbeddingCache(next);
next = applyMemoryFlush(next);
return next;
}

View File

@ -12,6 +12,7 @@ import {
resolveControlUiLinks,
waitForGatewayReachable,
} from "../onboard-helpers.js";
import { applyNonInteractiveMemoryDefaults } from "../onboard-memory.js";
import type { OnboardOptions } from "../onboard-types.js";
import { inferAuthChoiceFromFlags } from "./local/auth-choice-inference.js";
import { applyNonInteractiveGatewayConfig } from "./local/gateway-config.js";
@ -125,6 +126,8 @@ export async function runNonInteractiveOnboardingLocal(params: {
nextConfig = applyNonInteractiveSkillsConfig({ nextConfig, opts, runtime });
nextConfig = applyNonInteractiveMemoryDefaults(nextConfig);
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig);
logConfigUpdated(runtime);

View File

@ -545,6 +545,10 @@ export async function runOnboardingWizard(
const { setupInternalHooks } = await import("../commands/onboard-hooks.js");
nextConfig = await setupInternalHooks(nextConfig, runtime, prompter);
// Memory optimization (hybrid search, caching, compaction flush)
const { setupMemoryOptimization } = await import("../commands/onboard-memory.js");
nextConfig = await setupMemoryOptimization(nextConfig, runtime, prompter);
nextConfig = onboardHelpers.applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig);