From 65cc1b3e3c0a3eb14fe7f9a5e4adcf05ef394948 Mon Sep 17 00:00:00 2001 From: 0xVox Date: Tue, 2 Jun 2026 23:55:41 -0700 Subject: [PATCH] fix(openclaw): migrate example plugin memory search to the v1 POST + Filters DSL API The example plugin's memory recall never worked against the v1 backend. searchMemories spoke the v0 dialect on both sides: - Request: GET /api/v1/memories/search with a flat top-level user_id and a retrieve_method field. The v1 route is POST-only (GET -> 405); even as POST, SearchMemoriesRequest requires a Filters DSL object (user_id/group_id must sit inside `filters`), so a flat top-level user_id -> 422 "Field required: filters"; and the method field is `method`, not `retrieve_method`. - Response: it read r.result.memories / r.result.pending_messages, but the v1 response is { data: { episodes, profiles, raw_messages, ... } }, so even a successful call surfaced zero memories. Migrate searchMemories to the v1 contract end-to-end while preserving its return shape (parseSearchResponse and the assembler are untouched): build the v1 POST envelope with filters:{user_id,group_id?} + method, and map data.episodes -> memories (tagging memory_type so the episodic filter matches) and data.raw_messages -> pending_messages (flattening content_items to text). Verified live against EverCore on :1995: old GET -> 405; flat top-level user_id (POST) -> 422 "Field required: filters"; fixed body (POST + filters + method) -> 200 with "filters_applied":{"user_id":...}; the real searchMemories() driven end-to-end (method=keyword) returns status:"ok" and maps the response with no error. Adds offline test/search-memories.test.js (node --test) asserting the v1 request envelope and that the v1 response maps into the caller contract and is consumable by the existing parseSearchResponse (2 passed). The v1 demo migration (#191, PR #196) covered methods/evermemos/demo/* and docs but not this example plugin; this finishes that migration for the plugin's search path. Scope: src/api.js (searchMemories only) + its regression test. saveMemories is migrated separately as the #237 fix. Co-authored-by: CZH-THU Co-Authored-By: Claude Opus 4.8 (1M context) --- .../examples/openclaw-plugin/src/api.js | 50 +++++-- .../test/search-memories.test.js | 124 ++++++++++++++++++ 2 files changed, 163 insertions(+), 11 deletions(-) create mode 100644 methods/EverCore/examples/openclaw-plugin/test/search-memories.test.js diff --git a/methods/EverCore/examples/openclaw-plugin/src/api.js b/methods/EverCore/examples/openclaw-plugin/src/api.js index eb7ee9fc..f31f5632 100644 --- a/methods/EverCore/examples/openclaw-plugin/src/api.js +++ b/methods/EverCore/examples/openclaw-plugin/src/api.js @@ -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)); @@ -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 = "" }) { diff --git a/methods/EverCore/examples/openclaw-plugin/test/search-memories.test.js b/methods/EverCore/examples/openclaw-plugin/test/search-memories.test.js new file mode 100644 index 00000000..d81da15b --- /dev/null +++ b/methods/EverCore/examples/openclaw-plugin/test/search-memories.test.js @@ -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/); +});