Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 39 additions & 11 deletions methods/EverCore/examples/openclaw-plugin/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function messageId(idSeed, role, content) {
}

export async function searchMemories(cfg, params, log = noop) {
const { memory_types, ...baseParams } = params;
const { memory_types, user_id, group_id, retrieve_method, ...baseParams } = params;

const SEARCHABLE = new Set(["episodic_memory"]);
const searchTypes = (memory_types ?? []).filter((t) => SEARCHABLE.has(t));
Expand All @@ -23,18 +23,46 @@ export async function searchMemories(cfg, params, log = noop) {
return { status: "ok", result: { memories: [], pending_messages: [] } };
}

const p = { ...baseParams, memory_types: searchTypes };
log.info(`${TAG} GET /api/v1/memories/search`);
const r = await request(cfg, "GET", "/api/v1/memories/search", p);
log.info(`${TAG} GET response`);
// SearchMemoriesRequest (v1) is a POST JSON body. A GET request hits the
// POST-only route and 405s; a flat top-level user_id then 422s ("Field
// required: filters") because user_id/group_id must live inside a Filters DSL
// object, and the retrieval-method field is `method`, not `retrieve_method`.
// Build the v1 request envelope so the search reaches and is accepted.
const filters = {};
if (user_id) filters.user_id = user_id;
if (group_id) filters.group_id = group_id;

return {
status: "ok",
result: {
memories: r?.result?.memories ?? [],
pending_messages: r?.result?.pending_messages ?? [],
},
const body = {
...baseParams, // query, top_k
memory_types: searchTypes,
filters,
...(retrieve_method ? { method: retrieve_method } : {}),
};

log.info(`${TAG} POST /api/v1/memories/search`);
const r = await request(cfg, "POST", "/api/v1/memories/search", body);
log.info(`${TAG} POST response`);

// The v1 response is { data: { episodes, profiles, raw_messages, ... } }, not
// the v0 { result: { memories, pending_messages } } the caller consumes. Map
// episodes -> memories (tagging memory_type so the episodic filter matches)
// and raw_messages -> pending_messages (flattening content_items to text).
const data = r?.data ?? {};
const memories = (data.episodes ?? []).map((e) => ({
memory_type: "episodic_memory",
score: e.score ?? 0,
summary: e.summary,
episode: e.episode,
subject: e.subject,
timestamp: e.timestamp,
}));
const pending_messages = (data.raw_messages ?? []).map((m) => ({
content: (m.content_items ?? []).map((c) => c?.text ?? "").join(" ").trim(),
sender_name: m.sender_name,
created_at: m.created_at ?? m.timestamp,
}));

return { status: "ok", result: { memories, pending_messages } };
}

export async function saveMemories(cfg, { userId, groupId, messages = [], flush = false, idSeed = "" }) {
Expand Down
124 changes: 124 additions & 0 deletions methods/EverCore/examples/openclaw-plugin/test/search-memories.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import test from "node:test";

import { searchMemories } from "../src/api.js";
import { parseSearchResponse } from "../src/prompt.js";

// A canned v1 search 200 body: { data: { episodes, raw_messages, ... } }.
const V1_RESPONSE = {
data: {
episodes: [
{
id: "ep1",
user_id: "alice",
summary: "Alice likes dark roast coffee",
subject: "coffee preference",
episode: "Alice mentioned she likes dark roast coffee.",
score: 1.7,
timestamp: "2026-06-03T05:00:00Z",
},
],
raw_messages: [
{
sender_name: "alice",
content_items: [{ type: "text", text: "and oat milk" }],
created_at: "2026-06-03T05:01:00Z",
},
],
profiles: [],
agent_memory: null,
query: { text: "coffee", method: "keyword", filters_applied: { user_id: "alice" } },
},
};

function stubFetch(captured) {
const real = globalThis.fetch;
globalThis.fetch = async (url, opts) => {
captured.push({ url: String(url), opts });
return {
ok: true,
status: 200,
async json() {
return V1_RESPONSE;
},
async text() {
return "";
},
};
};
return () => {
globalThis.fetch = real;
};
}

// Regression for the openclaw plugin's memory recall: the v0 form
// (GET + flat top-level user_id + `retrieve_method`) 405s then 422s against the
// v1 search route. This asserts the request is the v1 envelope.
test("searchMemories POSTs a v1 SearchMemoriesRequest with a Filters DSL", async () => {
const cfg = { serverUrl: "http://localhost:1995" };
const captured = [];
const restore = stubFetch(captured);
try {
await searchMemories(cfg, {
query: "coffee",
user_id: "alice",
group_id: undefined,
memory_types: ["episodic_memory", "profile"],
retrieve_method: "hybrid",
top_k: 10,
});
} finally {
restore();
}

assert.equal(captured.length, 1);
assert.equal(captured[0].opts.method, "POST", "search must POST (GET 405s on the v1 route)");
assert.match(captured[0].url, /\/api\/v1\/memories\/search$/);

const body = JSON.parse(captured[0].opts.body);
// user_id must live inside the Filters DSL, not at the top level.
assert.deepEqual(body.filters, { user_id: "alice" }, "user_id must live in the Filters DSL");
assert.equal("user_id" in body, false, "no top-level user_id");
// retrieve_method must be renamed to the DTO's `method` field.
assert.equal(body.method, "hybrid");
assert.equal("retrieve_method" in body, false);
assert.equal(body.query, "coffee");
assert.equal(body.top_k, 10);
// Only backend-searchable types are sent.
assert.deepEqual(body.memory_types, ["episodic_memory"]);
});

test("searchMemories maps the v1 { data: { episodes, raw_messages } } response into the caller contract", async () => {
const cfg = { serverUrl: "http://localhost:1995" };
const restore = stubFetch([]);
let out;
try {
out = await searchMemories(cfg, {
query: "coffee",
user_id: "alice",
memory_types: ["episodic_memory"],
top_k: 5,
});
} finally {
restore();
}

assert.equal(out.status, "ok");
// episodes -> memories, tagged so the downstream episodic filter matches.
assert.equal(out.result.memories.length, 1);
const m = out.result.memories[0];
assert.equal(m.memory_type, "episodic_memory");
assert.equal(m.summary, "Alice likes dark roast coffee");
assert.equal(m.score, 1.7);
// raw_messages -> pending_messages, content_items flattened to text.
assert.equal(out.result.pending_messages.length, 1);
assert.equal(out.result.pending_messages[0].content, "and oat milk");

// End-to-end: the existing parseSearchResponse must consume the mapped shape.
const parsed = parseSearchResponse(out);
assert.ok(parsed, "parseSearchResponse accepts the mapped result");
assert.equal(parsed.episodic.length, 1);
assert.match(parsed.episodic[0].text, /coffee preference: Alice likes dark roast coffee/);
assert.equal(parsed.pending.length, 1);
assert.match(parsed.pending[0].text, /alice: and oat milk/);
});