mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
28630a9a65
* feat(memory): add provenance and recall metadata to the memory index * feat(memory): provenance-gated promotion and capture hygiene * feat(dreaming): LLM consolidation with deterministic gates, on by default * feat(active-memory): deterministic recall lane with escalation default * feat(memory): user model file and standing intents * docs(memory): document the memory architecture * fix(memory): live-QA fixes — metadata writers, provenance classes, intent scope, claim accumulation
199 lines
7.4 KiB
TypeScript
199 lines
7.4 KiB
TypeScript
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildTriggerRecallContext,
|
|
isPromotedTrustedMemoryEntry,
|
|
MAX_TRIGGER_CONTEXT_CHARS,
|
|
scoreTriggerMatch,
|
|
resolveTriggerRecall,
|
|
selectStrongTriggerMatches,
|
|
STRONG_TRIGGER_MATCH_SCORE,
|
|
} from "./trigger-recall.js";
|
|
|
|
const hoisted = vi.hoisted(() => ({
|
|
getManager: vi.fn(),
|
|
search: vi.fn(),
|
|
listTriggerCandidates: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/memory-host-search", () => ({
|
|
getActiveMemorySearchManager: (...args: unknown[]) => hoisted.getManager(...args),
|
|
}));
|
|
|
|
function result(overrides: Partial<MemorySearchResult> = {}): MemorySearchResult {
|
|
return {
|
|
path: "MEMORY.md",
|
|
startLine: 1,
|
|
endLine: 2,
|
|
score: 0.8,
|
|
snippet: "User prefers aisle seats and extra connection time.",
|
|
source: "memory",
|
|
triggers: "when booking a flight; seat preferences",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("active-memory trigger recall", () => {
|
|
beforeEach(() => {
|
|
hoisted.getManager.mockReset().mockResolvedValue({
|
|
manager: { search: hoisted.search, listTriggerCandidates: hoisted.listTriggerCandidates },
|
|
});
|
|
hoisted.search.mockReset();
|
|
hoisted.listTriggerCandidates.mockReset();
|
|
});
|
|
|
|
it("matches trigger phrases deterministically", () => {
|
|
expect(scoreTriggerMatch("Can you help when booking a flight?", result())).toBeGreaterThan(0.8);
|
|
expect(scoreTriggerMatch("Explain SQLite indexes", result())).toBeLessThan(0.5);
|
|
expect(
|
|
scoreTriggerMatch("This party starts at eight", result({ score: 0.2, triggers: "art" })),
|
|
).toBeLessThan(0.65);
|
|
// Single-word concept triggers (the promotion writer's output) cap at
|
|
// 0.85 * 0.8 = 0.68 with zero relevance; the 0.65 threshold must admit them.
|
|
expect(
|
|
scoreTriggerMatch("Project status", result({ score: 0, triggers: "project" })),
|
|
).toBeCloseTo(0.68);
|
|
expect(
|
|
scoreTriggerMatch("Project status", result({ score: 0, triggers: "project" })),
|
|
).toBeGreaterThanOrEqual(STRONG_TRIGGER_MATCH_SCORE);
|
|
});
|
|
|
|
it("limits automatic injection to curated or trusted-origin entries", () => {
|
|
expect(isPromotedTrustedMemoryEntry(result())).toBe(true);
|
|
expect(isPromotedTrustedMemoryEntry(result({ path: "USER.md" }))).toBe(true);
|
|
expect(isPromotedTrustedMemoryEntry(result({ path: "memory/2026-07-27.md" }))).toBe(false);
|
|
expect(isPromotedTrustedMemoryEntry(result({ source: "sessions" }))).toBe(false);
|
|
expect(
|
|
isPromotedTrustedMemoryEntry(result({ path: "memory/promoted.md", originClass: "owner" })),
|
|
).toBe(true);
|
|
|
|
const matches = selectStrongTriggerMatches("when booking a flight", [
|
|
result(),
|
|
result({ path: "USER.md", startLine: 3 }),
|
|
result({ path: "memory/2026-07-27.md", startLine: 4 }),
|
|
result({ source: "sessions", path: "session.jsonl", startLine: 5 }),
|
|
]);
|
|
expect(matches.map((entry) => entry.path)).toEqual(["MEMORY.md", "USER.md"]);
|
|
|
|
const provenanceMatches = selectStrongTriggerMatches("when booking a flight", [
|
|
result({ path: "memory/untrusted.md", originClass: "untrusted", score: 1 }),
|
|
result({ path: "memory/owner.md", originClass: "owner", score: 1 }),
|
|
]);
|
|
expect(provenanceMatches.map((entry) => entry.path)).toEqual(["memory/owner.md"]);
|
|
});
|
|
|
|
it("searches lexical-only so the reply path never embeds the query", async () => {
|
|
hoisted.search.mockResolvedValue([result()]);
|
|
hoisted.listTriggerCandidates.mockResolvedValue([]);
|
|
await resolveTriggerRecall({
|
|
cfg: {} as never,
|
|
agentId: "main",
|
|
query: "flight booking",
|
|
message: "Help when booking a flight",
|
|
});
|
|
expect(hoisted.search).toHaveBeenCalledWith(
|
|
"flight booking",
|
|
expect.objectContaining({ lexicalOnly: true, qmdSearchModeOverride: "search" }),
|
|
);
|
|
});
|
|
|
|
it("skips backends that cannot enumerate curated trigger candidates", async () => {
|
|
hoisted.getManager.mockResolvedValueOnce({ manager: { search: hoisted.search } });
|
|
await expect(
|
|
resolveTriggerRecall({
|
|
cfg: {} as never,
|
|
agentId: "main",
|
|
query: "flight booking",
|
|
message: "Help when booking a flight",
|
|
}),
|
|
).resolves.toEqual({ hasStrongHit: false, injectedCount: 0 });
|
|
expect(hoisted.search).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("matches curated trigger candidates even when text retrieval fails", async () => {
|
|
hoisted.search.mockRejectedValue(new Error("embedding unavailable"));
|
|
hoisted.listTriggerCandidates.mockResolvedValue([result({ score: 0 })]);
|
|
const recalled = await resolveTriggerRecall({
|
|
cfg: {} as never,
|
|
agentId: "main",
|
|
query: "flight booking",
|
|
message: "Help when booking a flight",
|
|
});
|
|
expect(recalled.hasStrongHit).toBe(true);
|
|
expect(recalled.injectedCount).toBe(1);
|
|
expect(recalled.context).toContain("aisle seats");
|
|
});
|
|
|
|
it("aborts when trigger-candidate enumeration does not settle", async () => {
|
|
hoisted.search.mockResolvedValue([]);
|
|
hoisted.listTriggerCandidates.mockImplementation(() => new Promise(() => {}));
|
|
const controller = new AbortController();
|
|
const recalled = resolveTriggerRecall({
|
|
cfg: {} as never,
|
|
agentId: "main",
|
|
query: "flight booking",
|
|
message: "Help when booking a flight",
|
|
signal: controller.signal,
|
|
});
|
|
|
|
controller.abort(new Error("deadline reached"));
|
|
|
|
await expect(recalled).rejects.toThrow("deadline reached");
|
|
});
|
|
|
|
it("aborts when memory-manager acquisition does not settle", async () => {
|
|
hoisted.getManager.mockImplementation(() => new Promise(() => {}));
|
|
const controller = new AbortController();
|
|
const recalled = resolveTriggerRecall({
|
|
cfg: {} as never,
|
|
agentId: "main",
|
|
query: "flight booking",
|
|
message: "Help when booking a flight",
|
|
signal: controller.signal,
|
|
});
|
|
|
|
controller.abort(new Error("manager deadline reached"));
|
|
|
|
await expect(recalled).rejects.toThrow("manager deadline reached");
|
|
expect(hoisted.search).not.toHaveBeenCalled();
|
|
expect(hoisted.listTriggerCandidates).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not start lookup work when the deadline already expired", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("already expired"));
|
|
|
|
await expect(
|
|
resolveTriggerRecall({
|
|
cfg: {} as never,
|
|
agentId: "main",
|
|
query: "flight booking",
|
|
message: "Help when booking a flight",
|
|
signal: controller.signal,
|
|
}),
|
|
).rejects.toThrow("already expired");
|
|
expect(hoisted.getManager).not.toHaveBeenCalled();
|
|
expect(hoisted.search).not.toHaveBeenCalled();
|
|
expect(hoisted.listTriggerCandidates).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("injects at most three matches inside the bounded active-memory wrapper", () => {
|
|
const matches = selectStrongTriggerMatches(
|
|
"when booking a flight",
|
|
Array.from({ length: 5 }, (_, index) =>
|
|
result({
|
|
path: index % 2 === 0 ? "MEMORY.md" : "USER.md",
|
|
startLine: index + 1,
|
|
snippet: `${String(index)} ${"x".repeat(900)}`,
|
|
}),
|
|
),
|
|
);
|
|
const context = buildTriggerRecallContext(matches);
|
|
expect(matches).toHaveLength(3);
|
|
expect(context).toContain("<active_memory_plugin>");
|
|
expect(context).toContain("</active_memory_plugin>");
|
|
expect(context?.length).toBeLessThanOrEqual(MAX_TRIGGER_CONTEXT_CHARS + 80);
|
|
expect(context).not.toContain("3 x");
|
|
});
|
|
});
|