mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
31fc15b8dd
* fix(memory-wiki): bound search snippets before model context * fix(memory-wiki): bound compiled prompt digest context
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
// Memory Wiki tests cover synchronous guidance and async compiled prompt preparation.
|
|
import fsSync from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../api.js";
|
|
import {
|
|
activateMemoryWikiCompiledCacheOwner,
|
|
configureMemoryWikiCompiledCacheStore,
|
|
createMemoryWikiCompiledCachePublicationId,
|
|
resolveMemoryWikiCompiledCacheGeneration,
|
|
writeMemoryWikiCompiledCache,
|
|
type MemoryWikiCompiledCacheSnapshot,
|
|
type MemoryWikiCompiledDigestClaim,
|
|
type MemoryWikiCompiledDigestPage,
|
|
} from "./compiled-cache.js";
|
|
import {
|
|
resolveMemoryWikiAgentConfig,
|
|
resolveMemoryWikiConfig,
|
|
type ResolvedMemoryWikiConfig,
|
|
} from "./config.js";
|
|
import {
|
|
appendMemoryWikiLog,
|
|
ensureMemoryWikiVaultGeneration,
|
|
loadMemoryWikiValidatedVaultIdentity,
|
|
loadMemoryWikiVaultIdentity,
|
|
resolveMemoryWikiVaultSourceGeneration,
|
|
} from "./log.js";
|
|
import {
|
|
createWikiPromptSectionBuilder,
|
|
createWikiPromptSectionPreparer,
|
|
} from "./prompt-section.js";
|
|
import { createMemoryWikiTestHarness } from "./test-helpers.js";
|
|
|
|
const { configureCompiledCacheStore } = createMemoryWikiTestHarness();
|
|
let suiteRoot = "";
|
|
|
|
beforeAll(async () => {
|
|
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-prompt-suite-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
configureMemoryWikiCompiledCacheStore(undefined);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (suiteRoot) {
|
|
await fs.rm(suiteRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
type PromptPageFixture = Pick<MemoryWikiCompiledDigestPage, "title" | "kind" | "claimCount"> &
|
|
Omit<Partial<MemoryWikiCompiledDigestPage>, "topClaims"> & {
|
|
topClaims?: Array<Partial<MemoryWikiCompiledDigestClaim> & { text: string }>;
|
|
};
|
|
|
|
async function seedCompiledDigest(params: {
|
|
config: ResolvedMemoryWikiConfig;
|
|
claimCount: number;
|
|
contradictionCount?: number;
|
|
pages: PromptPageFixture[];
|
|
}): Promise<void> {
|
|
configureCompiledCacheStore();
|
|
await fs.mkdir(path.join(params.config.vault.path, ".openclaw-wiki"), { recursive: true });
|
|
await Promise.all([
|
|
fs.writeFile(path.join(params.config.vault.path, "WIKI.md"), "# Memory Wiki\n", "utf8"),
|
|
fs.writeFile(path.join(params.config.vault.path, ".openclaw-wiki", "log.jsonl"), "", "utf8"),
|
|
]);
|
|
activateMemoryWikiCompiledCacheOwner(
|
|
params.config,
|
|
await ensureMemoryWikiVaultGeneration(params.config.vault.path),
|
|
);
|
|
const snapshot: MemoryWikiCompiledCacheSnapshot = {
|
|
digest: {
|
|
claimCount: params.claimCount,
|
|
contradictionCount: params.contradictionCount ?? 0,
|
|
pages: params.pages.map((page, index) => ({
|
|
...page,
|
|
path: page.path ?? `entities/page-${index}.md`,
|
|
aliases: page.aliases ?? [],
|
|
sourceIds: page.sourceIds ?? [],
|
|
questions: page.questions ?? [],
|
|
contradictions: page.contradictions ?? [],
|
|
bestUsedFor: page.bestUsedFor ?? [],
|
|
notEnoughFor: page.notEnoughFor ?? [],
|
|
relationshipCount: page.relationshipCount ?? 0,
|
|
topRelationships: page.topRelationships ?? [],
|
|
topClaims: (page.topClaims ?? []).map((claim) =>
|
|
Object.assign({ status: "supported", freshnessLevel: "unknown" }, claim),
|
|
),
|
|
})),
|
|
},
|
|
claims: [],
|
|
};
|
|
const publicationId = createMemoryWikiCompiledCachePublicationId();
|
|
const reservationId = createMemoryWikiCompiledCachePublicationId();
|
|
const parentPublicationId = (await loadMemoryWikiVaultIdentity(params.config.vault.path))
|
|
.compiledCachePublicationId;
|
|
await appendMemoryWikiLog(params.config.vault.path, {
|
|
type: "compile",
|
|
timestamp: "2026-07-17T00:00:00.000Z",
|
|
details: { compiledCacheReservationId: reservationId },
|
|
});
|
|
const sourceGeneration = await resolveMemoryWikiVaultSourceGeneration(params.config.vault.path);
|
|
await appendMemoryWikiLog(params.config.vault.path, {
|
|
type: "compile",
|
|
timestamp: "2026-07-17T00:00:00.000Z",
|
|
details: {
|
|
compiledCachePublicationId: publicationId,
|
|
compiledCacheParentPublicationId: parentPublicationId,
|
|
compiledCacheReservationId: reservationId,
|
|
compiledCacheSourceGeneration: sourceGeneration,
|
|
},
|
|
});
|
|
await writeMemoryWikiCompiledCache(
|
|
params.config,
|
|
snapshot,
|
|
resolveMemoryWikiCompiledCacheGeneration(snapshot),
|
|
publicationId,
|
|
parentPublicationId,
|
|
async () => {},
|
|
async () => {},
|
|
() => loadMemoryWikiValidatedVaultIdentity(params.config.vault.path),
|
|
);
|
|
}
|
|
|
|
function createStaticPreparer(config: ResolvedMemoryWikiConfig) {
|
|
return createWikiPromptSectionPreparer({ config, resolveConfig: () => config });
|
|
}
|
|
|
|
describe("Memory Wiki prompt section", () => {
|
|
const buildGuidance = createWikiPromptSectionBuilder();
|
|
|
|
it("prefers shared memory corpus guidance when memory tools are available", () => {
|
|
const lines = buildGuidance({
|
|
availableTools: new Set(["memory_search", "memory_get", "wiki_search", "wiki_get"]),
|
|
});
|
|
|
|
expect(lines.join("\n")).toContain("`memory_search` with `corpus=all`");
|
|
expect(lines.join("\n")).toContain("`memory_get` with `corpus=wiki` or `corpus=all`");
|
|
expect(lines.join("\n")).toContain("wiki-specific ranking or provenance details");
|
|
});
|
|
|
|
it("stays empty when no wiki or memory-adjacent tools are registered", () => {
|
|
expect(buildGuidance({ availableTools: new Set(["web_search"]) })).toStrictEqual([]);
|
|
});
|
|
|
|
it("prepares a compact compiled digest from SQLite", async () => {
|
|
const config = resolveMemoryWikiConfig({
|
|
vault: { path: path.join(suiteRoot, "digest-enabled") },
|
|
context: { includeCompiledDigestPrompt: true },
|
|
});
|
|
await seedCompiledDigest({
|
|
config,
|
|
claimCount: 8,
|
|
contradictionCount: 1,
|
|
pages: [
|
|
{
|
|
title: "Alpha",
|
|
kind: "entity",
|
|
claimCount: 3,
|
|
questions: ["Still active?"],
|
|
contradictions: ["Conflicts with source.beta"],
|
|
topClaims: [
|
|
{
|
|
text: "Alpha uses PostgreSQL for production writes.",
|
|
confidence: 0.91,
|
|
freshnessLevel: "fresh",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
const lines = await createStaticPreparer(config)({ availableTools: new Set() });
|
|
|
|
expect(lines.join("\n")).toContain("## Compiled Wiki Snapshot");
|
|
expect(lines.join("\n")).toContain(
|
|
"Alpha: entity, 3 claims, 1 open questions, 1 contradiction notes",
|
|
);
|
|
expect(lines.join("\n")).toContain("Alpha uses PostgreSQL for production writes.");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "oversized structured claims without splitting UTF-16 surrogate pairs",
|
|
pages: [
|
|
{
|
|
title: "Alpha",
|
|
kind: "entity" as const,
|
|
claimCount: 1,
|
|
topClaims: [{ text: `${"c".repeat(699)}🤖${"c".repeat(20_000)}` }],
|
|
},
|
|
],
|
|
retained: "c".repeat(699),
|
|
omitted: "🤖",
|
|
},
|
|
{
|
|
name: "oversized page titles without splitting UTF-16 surrogate pairs",
|
|
pages: [
|
|
{
|
|
title: `${"t".repeat(159)}🤖${"t".repeat(20_000)}`,
|
|
kind: "entity" as const,
|
|
claimCount: 1,
|
|
topClaims: [{ text: "The claim remains visible after its page title." }],
|
|
},
|
|
],
|
|
retained: "The claim remains visible after its page title.",
|
|
omitted: "🤖",
|
|
},
|
|
{
|
|
name: "the complete digest across multiple independently bounded claims",
|
|
pages: Array.from({ length: 4 }, (_, pageIndex) => ({
|
|
title: `Page ${pageIndex}`,
|
|
kind: "entity" as const,
|
|
claimCount: 2,
|
|
topClaims: Array.from({ length: 2 }, (_claim, claimIndex) => ({
|
|
text: `claim ${pageIndex}-${claimIndex} ${"x".repeat(680)}`,
|
|
})),
|
|
})),
|
|
retained: "claim 0-0",
|
|
omitted: "claim 3-1",
|
|
},
|
|
])(
|
|
"hard-bounds $name before model-context injection",
|
|
async ({ name, pages, retained, omitted }) => {
|
|
const config = resolveMemoryWikiConfig({
|
|
vault: { path: path.join(suiteRoot, `digest-bounded-${name.replaceAll(" ", "-")}`) },
|
|
context: { includeCompiledDigestPrompt: true },
|
|
});
|
|
await seedCompiledDigest({ config, claimCount: pages.length * 2, pages });
|
|
|
|
const lines = await createStaticPreparer(config)({ availableTools: new Set() });
|
|
const prompt = lines.join("\n");
|
|
|
|
expect(prompt).toContain("## Compiled Wiki Snapshot");
|
|
expect(prompt).toContain(retained);
|
|
expect(prompt).not.toContain(omitted);
|
|
expect(prompt.length).toBeLessThanOrEqual(2_800);
|
|
expect(prompt).not.toMatch(
|
|
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/,
|
|
);
|
|
},
|
|
);
|
|
|
|
it("keeps the digest disabled by default", async () => {
|
|
const config = resolveMemoryWikiConfig({
|
|
vault: { path: path.join(suiteRoot, "digest-disabled") },
|
|
});
|
|
await seedCompiledDigest({
|
|
config,
|
|
claimCount: 1,
|
|
pages: [{ title: "Alpha", kind: "entity", claimCount: 1 }],
|
|
});
|
|
|
|
await expect(createStaticPreparer(config)({ availableTools: new Set() })).resolves.toEqual([]);
|
|
});
|
|
|
|
it("stabilizes digest ordering for prompt-cache-friendly output", async () => {
|
|
const config = resolveMemoryWikiConfig({
|
|
vault: { path: path.join(suiteRoot, "digest-stable") },
|
|
context: { includeCompiledDigestPrompt: true },
|
|
});
|
|
const pages: PromptPageFixture[] = [
|
|
{
|
|
title: "Zulu",
|
|
kind: "concept",
|
|
claimCount: 2,
|
|
topClaims: [{ text: "Zulu fallback note.", confidence: 0.3, freshnessLevel: "stale" }],
|
|
},
|
|
{
|
|
title: "Alpha",
|
|
kind: "entity",
|
|
claimCount: 4,
|
|
questions: ["Still active?"],
|
|
contradictions: ["Conflicts with source.beta"],
|
|
topClaims: [
|
|
{ text: "Alpha was renamed in 2026.", confidence: 0.42, freshnessLevel: "aging" },
|
|
{
|
|
text: "Alpha uses PostgreSQL for production writes.",
|
|
confidence: 0.91,
|
|
freshnessLevel: "fresh",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
await seedCompiledDigest({ config, claimCount: 6, contradictionCount: 1, pages });
|
|
const firstLines = await createStaticPreparer(config)({ availableTools: new Set() });
|
|
const firstPage = expectDefined(pages[0], "first Memory Wiki digest page");
|
|
const secondPage = expectDefined(pages[1], "second Memory Wiki digest page");
|
|
await seedCompiledDigest({
|
|
config,
|
|
claimCount: 6,
|
|
contradictionCount: 1,
|
|
pages: [{ ...secondPage, topClaims: secondPage.topClaims?.toReversed() }, firstPage],
|
|
});
|
|
const secondLines = await createStaticPreparer(config)({ availableTools: new Set() });
|
|
|
|
expect(firstLines).toEqual(secondLines);
|
|
expect(firstLines.join("\n")).toContain(
|
|
"Alpha uses PostgreSQL for production writes. (status supported, confidence 0.91, freshness fresh)",
|
|
);
|
|
});
|
|
|
|
it("does no filesystem work during synchronous prompt guidance", () => {
|
|
const readFileSync = vi.spyOn(fsSync, "readFileSync");
|
|
|
|
expect(buildGuidance({ availableTools: new Set(["web_search"]) })).toEqual([]);
|
|
expect(readFileSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prepares only the invoking agent's compiled digest", async () => {
|
|
const rootDir = path.join(suiteRoot, "agent-digests");
|
|
const appConfig = {
|
|
agents: { list: [{ id: "support", default: true }, { id: "marketing" }] },
|
|
} as OpenClawConfig;
|
|
const config = resolveMemoryWikiConfig({
|
|
vault: { scope: "agent", path: rootDir },
|
|
context: { includeCompiledDigestPrompt: true },
|
|
});
|
|
for (const [agentId, marker] of [
|
|
["support", "SUPPORT_SENTINEL"],
|
|
["marketing", "MARKETING_SENTINEL"],
|
|
] as const) {
|
|
const agentConfig = resolveMemoryWikiAgentConfig({ config, appConfig, agentId });
|
|
await seedCompiledDigest({
|
|
config: agentConfig,
|
|
claimCount: 1,
|
|
pages: [
|
|
{
|
|
title: agentId,
|
|
kind: "entity",
|
|
claimCount: 1,
|
|
topClaims: [{ text: marker }],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
const prepare = createWikiPromptSectionPreparer({
|
|
config,
|
|
resolveConfig: (agentId) => resolveMemoryWikiAgentConfig({ config, appConfig, agentId }),
|
|
});
|
|
|
|
const support = await prepare({ availableTools: new Set(), agentId: "support" });
|
|
const marketing = await prepare({ availableTools: new Set(), agentId: "marketing" });
|
|
|
|
expect(support.join("\n")).toContain("SUPPORT_SENTINEL");
|
|
expect(support.join("\n")).not.toContain("MARKETING_SENTINEL");
|
|
expect(marketing.join("\n")).toContain("MARKETING_SENTINEL");
|
|
expect(marketing.join("\n")).not.toContain("SUPPORT_SENTINEL");
|
|
await expect(prepare({ availableTools: new Set() })).resolves.toEqual([]);
|
|
});
|
|
});
|