mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-06 23:01:08 +00:00
* memory-core: add dreaming promotion flow with weighted thresholds * docs(memory): mark dreaming as experimental * memory-core: address dreaming promotion review feedback * memory-core: harden short-term promotion concurrency * acpx: make abort-process test timer-independent * memory-core: simplify dreaming config with mode presets * memory-core: add /dreaming command and tighten recall tracking * ui: add Dreams tab with sleeping lobster animation Adds a new Dreams tab to the gateway UI under the Agent group. The tab is gated behind the memory-core dreaming config — it only appears in the sidebar when dreaming.mode is not 'off'. Features: - Sleeping vector lobster with breathing animation - Floating Z's, twinkling starfield, moon glow - Rotating dream phrase bubble (17 whimsical phrases) - Memory stats bar (short-term, long-term, promoted) - Active/idle visual states - 14 unit tests * plugins: fix --json stdout pollution from hook runner log The hook runner initialization message was using log.info() which writes to stdout via console.log, breaking JSON.parse() in the Docker smoke test for 'openclaw plugins list --json'. Downgrade to log.debug() so it only appears when debugging is enabled. * ui: keep Dreams tab visible when dreaming is off * tests: fix contracts and stabilize extension shards * memory-core: harden dreaming recall persistence and locking * fix: stabilize dreaming PR gates (#60569) (thanks @vignesh07) * test: fix rebase drift in telegram and plugin guards
212 lines
7.5 KiB
TypeScript
212 lines
7.5 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
getMemorySearchManagerMockCalls,
|
|
getReadAgentMemoryFileMockCalls,
|
|
resetMemoryToolMockState,
|
|
setMemoryBackend,
|
|
setMemoryReadFileImpl,
|
|
setMemorySearchImpl,
|
|
setMemoryWorkspaceDir,
|
|
type MemoryReadParams,
|
|
} from "../../../test/helpers/memory-tool-manager-mock.js";
|
|
import {
|
|
asOpenClawConfig,
|
|
createAutoCitationsMemorySearchTool,
|
|
createDefaultMemoryToolConfig,
|
|
createMemoryGetToolOrThrow,
|
|
createMemorySearchToolOrThrow,
|
|
expectUnavailableMemorySearchDetails,
|
|
} from "./tools.test-helpers.js";
|
|
|
|
async function waitFor<T>(task: () => Promise<T>, timeoutMs: number = 1500): Promise<T> {
|
|
const startedAt = Date.now();
|
|
let lastError: unknown;
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
try {
|
|
return await task();
|
|
} catch (error) {
|
|
lastError = error;
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 20);
|
|
});
|
|
}
|
|
}
|
|
if (lastError instanceof Error) {
|
|
throw lastError;
|
|
}
|
|
throw new Error("Timed out waiting for async test condition");
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetMemoryToolMockState({
|
|
backend: "builtin",
|
|
searchImpl: async () => [
|
|
{
|
|
path: "MEMORY.md",
|
|
startLine: 5,
|
|
endLine: 7,
|
|
score: 0.9,
|
|
snippet: "@@ -5,3 @@\nAssistant: noted",
|
|
source: "memory" as const,
|
|
},
|
|
],
|
|
readFileImpl: async (params: MemoryReadParams) => ({ text: "", path: params.relPath }),
|
|
});
|
|
});
|
|
|
|
describe("memory search citations", () => {
|
|
it("appends source information when citations are enabled", async () => {
|
|
setMemoryBackend("builtin");
|
|
const cfg = asOpenClawConfig({
|
|
memory: { citations: "on" },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
});
|
|
const tool = createMemorySearchToolOrThrow({ config: cfg });
|
|
const result = await tool.execute("call_citations_on", { query: "notes" });
|
|
const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
|
|
expect(details.results[0]?.snippet).toMatch(/Source: MEMORY.md#L5-L7/);
|
|
expect(details.results[0]?.citation).toBe("MEMORY.md#L5-L7");
|
|
});
|
|
|
|
it("leaves snippet untouched when citations are off", async () => {
|
|
setMemoryBackend("builtin");
|
|
const cfg = asOpenClawConfig({
|
|
memory: { citations: "off" },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
});
|
|
const tool = createMemorySearchToolOrThrow({ config: cfg });
|
|
const result = await tool.execute("call_citations_off", { query: "notes" });
|
|
const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
|
|
expect(details.results[0]?.snippet).not.toMatch(/Source:/);
|
|
expect(details.results[0]?.citation).toBeUndefined();
|
|
});
|
|
|
|
it("clamps decorated snippets to qmd injected budget", async () => {
|
|
setMemoryBackend("qmd");
|
|
const cfg = asOpenClawConfig({
|
|
memory: { citations: "on", backend: "qmd", qmd: { limits: { maxInjectedChars: 20 } } },
|
|
agents: { list: [{ id: "main", default: true }] },
|
|
});
|
|
const tool = createMemorySearchToolOrThrow({ config: cfg });
|
|
const result = await tool.execute("call_citations_qmd", { query: "notes" });
|
|
const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
|
|
expect(details.results[0]?.snippet.length).toBeLessThanOrEqual(20);
|
|
});
|
|
|
|
it("honors auto mode for direct chats", async () => {
|
|
setMemoryBackend("builtin");
|
|
const tool = createAutoCitationsMemorySearchTool("agent:main:discord:dm:u123");
|
|
const result = await tool.execute("auto_mode_direct", { query: "notes" });
|
|
const details = result.details as { results: Array<{ snippet: string }> };
|
|
expect(details.results[0]?.snippet).toMatch(/Source:/);
|
|
});
|
|
|
|
it("suppresses citations for auto mode in group chats", async () => {
|
|
setMemoryBackend("builtin");
|
|
const tool = createAutoCitationsMemorySearchTool("agent:main:discord:group:c123");
|
|
const result = await tool.execute("auto_mode_group", { query: "notes" });
|
|
const details = result.details as { results: Array<{ snippet: string }> };
|
|
expect(details.results[0]?.snippet).not.toMatch(/Source:/);
|
|
});
|
|
});
|
|
|
|
describe("memory tools", () => {
|
|
it("does not throw when memory_search fails (e.g. embeddings 429)", async () => {
|
|
setMemorySearchImpl(async () => {
|
|
throw new Error("openai embeddings failed: 429 insufficient_quota");
|
|
});
|
|
|
|
const cfg = createDefaultMemoryToolConfig();
|
|
const tool = createMemorySearchToolOrThrow({ config: cfg });
|
|
|
|
const result = await tool.execute("call_1", { query: "hello" });
|
|
expectUnavailableMemorySearchDetails(result.details, {
|
|
error: "openai embeddings failed: 429 insufficient_quota",
|
|
warning: "Memory search is unavailable because the embedding provider quota is exhausted.",
|
|
action: "Top up or switch embedding provider, then retry memory_search.",
|
|
});
|
|
});
|
|
|
|
it("does not throw when memory_get fails", async () => {
|
|
setMemoryReadFileImpl(async (_params: MemoryReadParams) => {
|
|
throw new Error("path required");
|
|
});
|
|
|
|
const tool = createMemoryGetToolOrThrow();
|
|
|
|
const result = await tool.execute("call_2", { path: "memory/NOPE.md" });
|
|
expect(result.details).toEqual({
|
|
path: "memory/NOPE.md",
|
|
text: "",
|
|
disabled: true,
|
|
error: "path required",
|
|
});
|
|
});
|
|
|
|
it("returns empty text without error when file does not exist (ENOENT)", async () => {
|
|
setMemoryReadFileImpl(async (_params: MemoryReadParams) => {
|
|
return { text: "", path: "memory/2026-02-19.md" };
|
|
});
|
|
|
|
const tool = createMemoryGetToolOrThrow();
|
|
|
|
const result = await tool.execute("call_enoent", { path: "memory/2026-02-19.md" });
|
|
expect(result.details).toEqual({
|
|
text: "",
|
|
path: "memory/2026-02-19.md",
|
|
});
|
|
});
|
|
|
|
it("uses the builtin direct memory file path for memory_get", async () => {
|
|
setMemoryBackend("builtin");
|
|
const tool = createMemoryGetToolOrThrow();
|
|
|
|
const result = await tool.execute("call_builtin_fast_path", { path: "memory/2026-02-19.md" });
|
|
|
|
expect(result.details).toEqual({
|
|
text: "",
|
|
path: "memory/2026-02-19.md",
|
|
});
|
|
expect(getReadAgentMemoryFileMockCalls()).toBe(1);
|
|
expect(getMemorySearchManagerMockCalls()).toBe(0);
|
|
});
|
|
|
|
it("persists short-term recall events from memory_search tool hits", async () => {
|
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-tools-recall-"));
|
|
try {
|
|
setMemoryBackend("builtin");
|
|
setMemoryWorkspaceDir(workspaceDir);
|
|
setMemorySearchImpl(async () => [
|
|
{
|
|
path: "memory/2026-04-03.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
score: 0.95,
|
|
snippet: "Move backups to S3 Glacier.",
|
|
source: "memory" as const,
|
|
},
|
|
]);
|
|
|
|
const tool = createMemorySearchToolOrThrow();
|
|
await tool.execute("call_recall_persist", { query: "glacier backup" });
|
|
|
|
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
|
|
const storeRaw = await waitFor(async () => await fs.readFile(storePath, "utf-8"));
|
|
const store = JSON.parse(storeRaw) as {
|
|
entries?: Record<string, { path: string; recallCount: number }>;
|
|
};
|
|
const entries = Object.values(store.entries ?? {});
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0]).toMatchObject({
|
|
path: "memory/2026-04-03.md",
|
|
recallCount: 1,
|
|
});
|
|
} finally {
|
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|