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
46 changes: 29 additions & 17 deletions methods/EverCore/examples/openclaw-plugin/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,34 +37,46 @@ export async function searchMemories(cfg, params, log = noop) {
};
}

export async function saveMemories(cfg, { userId, groupId, messages = [], flush = false, idSeed = "" }) {
export async function saveMemories(cfg, { userId, sessionId, messages = [], idSeed = "" }) {
if (!messages.length) return;
const stamp = Date.now();

const payloads = messages.map((msg, i) => {
// PersonalAddRequest (POST /api/v1/memories) is an ENVELOPE: a top-level
// user_id plus a `messages` array of MessageItem. Posting flat per-message
// bodies without a top-level user_id triggers HTTP 422
// ("Field required: user_id") — see issue #237.
const items = messages.map((msg, i) => {
const { role = "user", content = "" } = msg;
// Always use userId as sender so the backend stores a consistent user_id
// for both user and assistant messages. The `role` field distinguishes who spoke.
const sender = userId;
const senderName = role === "assistant" ? "assistant" : userId;
const isLast = i === messages.length - 1;

return {
const item = {
message_id: messageId(idSeed, role, content),
create_time: new Date(stamp + i).toISOString(),
// MessageItem.timestamp is a REQUIRED unix-milliseconds integer.
// The previous create_time(ISO) field was ignored by the converter and
// left timestamp missing, failing validation.
timestamp: stamp + i,
role,
sender,
sender_name: senderName,
content,
group_id: groupId,
group_name: groupId,
scene: "assistant",
raw_data_type: "AgentConversation",
...(flush && isLast && { flush: true }),
};

// Personal-scene sender_id rules (request_converter.py):
// role=user -> sender_id must equal user_id (or be omitted)
// role=assistant -> sender_id must NOT equal user_id (backend generates one)
// So only set sender_id for user turns; let the backend derive it for
// assistant turns to avoid a sender_id conflict.
if (role === "user") {
item.sender_id = userId;
}

return item;
});

for (const payload of payloads) {
await request(cfg, "POST", "/api/v1/memories", payload);
}
const body = {
user_id: userId,
messages: items,
...(sessionId ? { session_id: sessionId } : {}),
};

await request(cfg, "POST", "/api/v1/memories", body);
}
3 changes: 2 additions & 1 deletion methods/EverCore/examples/openclaw-plugin/src/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ export function createContextEngine(pluginMeta, pluginConfig, logger) {

await saveMemories(cfg, {
userId: cfg.userId,
groupId: cfg.groupId,
// sessionKey scopes the personal-add envelope to this conversation.
sessionId: sessionKey,
messages: converted,
idSeed: `${sessionKey}:${state.turnCount}`,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import test from "node:test";

import { saveMemories } from "../src/api.js";

// Regression for #237: POST /api/v1/memories was 422-ing with
// "Field required: user_id" because saveMemories sent flat per-message bodies.
// PersonalAddRequest is an envelope: top-level user_id + a messages[] array,
// each MessageItem carrying a required unix-milliseconds integer timestamp.
test("saveMemories posts a PersonalAddRequest envelope with top-level user_id (fix #237)", async () => {
const cfg = { serverUrl: "http://localhost:1995" };
const captured = [];

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

try {
await saveMemories(cfg, {
userId: "alice",
sessionId: "sess-1",
messages: [
{ role: "user", content: "hi" },
{ role: "assistant", content: "hello" },
],
idSeed: "k:1",
});
} finally {
globalThis.fetch = realFetch;
}

// A single batched POST to /api/v1/memories (not one request per message).
assert.equal(captured.length, 1, "expected exactly one batched POST");
assert.equal(captured[0].opts.method, "POST");
assert.match(captured[0].url, /\/api\/v1\/memories$/);

const body = JSON.parse(captured[0].opts.body);

// The 422 fix: top-level user_id must be present.
assert.equal(body.user_id, "alice", "body must carry a top-level user_id");
assert.equal(body.session_id, "sess-1");
assert.ok(Array.isArray(body.messages), "body must carry a messages array");
assert.equal(body.messages.length, 2);

// MessageItem.timestamp is a REQUIRED unix-milliseconds integer.
for (const m of body.messages) {
assert.equal(typeof m.timestamp, "number", "each message needs a unix-ms timestamp");
assert.equal(Number.isInteger(m.timestamp), true);
assert.equal("create_time" in m, false, "legacy create_time field must be gone");
}

// Personal-scene sender_id rules: user turn carries sender_id=user_id,
// assistant turn omits sender_id so the backend generates a distinct one.
const userMsg = body.messages.find((m) => m.role === "user");
const asstMsg = body.messages.find((m) => m.role === "assistant");
assert.equal(userMsg.sender_id, "alice");
assert.equal("sender_id" in asstMsg, false, "assistant turn must not pin sender_id to user_id");
});

test("saveMemories sends nothing when there are no messages", async () => {
const cfg = { serverUrl: "http://localhost:1995" };
let called = false;
const realFetch = globalThis.fetch;
globalThis.fetch = async () => {
called = true;
return { ok: true, status: 200, async json() { return {}; }, async text() { return ""; } };
};
try {
await saveMemories(cfg, { userId: "alice", messages: [] });
} finally {
globalThis.fetch = realFetch;
}
assert.equal(called, false, "no POST should be sent for an empty message batch");
});