feat(openclaw): add per-agent memory isolation for multi-agent setups (#4245)
This commit is contained in:
+30
-4
@@ -30,6 +30,26 @@ The agent tools (`memory_search`, `memory_list`) accept a `scope` parameter (`"s
|
|||||||
|
|
||||||
All new parameters are optional and backward-compatible — existing configurations work without changes.
|
All new parameters are optional and backward-compatible — existing configurations work without changes.
|
||||||
|
|
||||||
|
### Per-agent memory isolation
|
||||||
|
|
||||||
|
In multi-agent setups, each agent automatically gets its own memory namespace. Session keys following the pattern `agent:<agentId>:<uuid>` are parsed to derive isolated namespaces (`${userId}:agent:${agentId}`). Single-agent deployments are unaffected — plain session keys and `agent:main:*` keys resolve to the configured `userId`.
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
|
||||||
|
- The agent's session key is inspected on every recall/capture cycle
|
||||||
|
- If the key matches `agent:<name>:<uuid>`, memories are stored under `userId:agent:<name>`
|
||||||
|
- Different agents never see each other's memories unless explicitly queried
|
||||||
|
|
||||||
|
**Explicit cross-agent queries:**
|
||||||
|
|
||||||
|
All memory tools (`memory_search`, `memory_store`, `memory_list`, `memory_forget`) accept an optional `agentId` parameter to query another agent's namespace:
|
||||||
|
|
||||||
|
```
|
||||||
|
memory_search({ query: "user's tech stack", agentId: "researcher" })
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution priority: explicit `agentId` > explicit `userId` > session-derived > configured default.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -87,11 +107,11 @@ The agent gets five tools it can call during conversations:
|
|||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `memory_search` | Search memories by natural language |
|
| `memory_search` | Search memories by natural language. Optional `agentId` to scope to a specific agent. |
|
||||||
| `memory_list` | List all stored memories for a user |
|
| `memory_list` | List all stored memories for a user. Optional `agentId` to scope to a specific agent. |
|
||||||
| `memory_store` | Explicitly save a fact |
|
| `memory_store` | Explicitly save a fact. Optional `agentId` to store under a specific agent's namespace. |
|
||||||
| `memory_get` | Retrieve a memory by ID |
|
| `memory_get` | Retrieve a memory by ID |
|
||||||
| `memory_forget` | Delete by ID or by query |
|
| `memory_forget` | Delete by ID or by query. Optional `agentId` to scope deletion to a specific agent. |
|
||||||
|
|
||||||
## CLI
|
## CLI
|
||||||
|
|
||||||
@@ -107,6 +127,12 @@ openclaw mem0 search "what languages does the user know" --scope session
|
|||||||
|
|
||||||
# Stats
|
# Stats
|
||||||
openclaw mem0 stats
|
openclaw mem0 stats
|
||||||
|
|
||||||
|
# Search a specific agent's memories
|
||||||
|
openclaw mem0 search "user preferences" --agent researcher
|
||||||
|
|
||||||
|
# Stats for a specific agent
|
||||||
|
openclaw mem0 stats --agent researcher
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Regression tests for per-agent memory isolation helpers.
|
||||||
|
*
|
||||||
|
* Addresses review feedback: targeted coverage for auth/session state,
|
||||||
|
* malformed input, and the resolveUserId priority chain.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
extractAgentId,
|
||||||
|
effectiveUserId,
|
||||||
|
agentUserId,
|
||||||
|
resolveUserId,
|
||||||
|
} from "./index.ts";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// extractAgentId
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("extractAgentId", () => {
|
||||||
|
it("returns agentId from a well-formed session key", () => {
|
||||||
|
expect(extractAgentId("agent:researcher:550e8400-e29b")).toBe("researcher");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for the 'main' sentinel", () => {
|
||||||
|
expect(extractAgentId("agent:main:abc-123")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for undefined/null/empty input", () => {
|
||||||
|
expect(extractAgentId(undefined)).toBeUndefined();
|
||||||
|
expect(extractAgentId("")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for non-agent session keys", () => {
|
||||||
|
expect(extractAgentId("user:alice:xyz")).toBeUndefined();
|
||||||
|
expect(extractAgentId("some-random-uuid")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles keys with extra colons after the UUID portion", () => {
|
||||||
|
expect(extractAgentId("agent:beta:uuid:extra:stuff")).toBe("beta");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when agentId segment is empty", () => {
|
||||||
|
// pattern: agent::<uuid> — empty agentId
|
||||||
|
expect(extractAgentId("agent::some-uuid")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when key is only 'agent:' with no trailing colon", () => {
|
||||||
|
expect(extractAgentId("agent:")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-sensitive (Agent != agent)", () => {
|
||||||
|
expect(extractAgentId("Agent:researcher:uuid")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles whitespace-only agentId as truthy string", () => {
|
||||||
|
// " " is a non-empty match — returned as-is (validation is caller's job)
|
||||||
|
expect(extractAgentId("agent: :uuid")).toBe(" ");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// effectiveUserId
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("effectiveUserId", () => {
|
||||||
|
const base = "alice";
|
||||||
|
|
||||||
|
it("returns base userId when sessionKey is undefined", () => {
|
||||||
|
expect(effectiveUserId(base)).toBe("alice");
|
||||||
|
expect(effectiveUserId(base, undefined)).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns namespaced userId for agent session keys", () => {
|
||||||
|
expect(effectiveUserId(base, "agent:researcher:uuid-1")).toBe(
|
||||||
|
"alice:agent:researcher",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to base for 'main' agent sessions", () => {
|
||||||
|
expect(effectiveUserId(base, "agent:main:uuid-2")).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to base for non-agent session keys", () => {
|
||||||
|
expect(effectiveUserId(base, "plain-session-id")).toBe("alice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// agentUserId
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("agentUserId", () => {
|
||||||
|
it("produces the correct namespaced format", () => {
|
||||||
|
expect(agentUserId("alice", "researcher")).toBe("alice:agent:researcher");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty agentId (caller is responsible for validation)", () => {
|
||||||
|
expect(agentUserId("alice", "")).toBe("alice:agent:");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// resolveUserId — priority chain
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("resolveUserId", () => {
|
||||||
|
const base = "alice";
|
||||||
|
|
||||||
|
it("prefers explicit agentId over everything else", () => {
|
||||||
|
expect(
|
||||||
|
resolveUserId(
|
||||||
|
base,
|
||||||
|
{ agentId: "researcher", userId: "bob" },
|
||||||
|
"agent:beta:uuid",
|
||||||
|
),
|
||||||
|
).toBe("alice:agent:researcher");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses explicit userId when agentId is absent", () => {
|
||||||
|
expect(
|
||||||
|
resolveUserId(base, { userId: "bob" }, "agent:beta:uuid"),
|
||||||
|
).toBe("bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives from session key when both agentId and userId are absent", () => {
|
||||||
|
expect(
|
||||||
|
resolveUserId(base, {}, "agent:gamma:uuid"),
|
||||||
|
).toBe("alice:agent:gamma");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to base userId when nothing else is provided", () => {
|
||||||
|
expect(resolveUserId(base, {})).toBe("alice");
|
||||||
|
expect(resolveUserId(base, {}, undefined)).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores empty-string agentId (falsy)", () => {
|
||||||
|
expect(resolveUserId(base, { agentId: "" })).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores empty-string userId (falsy)", () => {
|
||||||
|
expect(resolveUserId(base, { userId: "" })).toBe("alice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cross-agent isolation sanity checks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("multi-agent isolation", () => {
|
||||||
|
const base = "user-42";
|
||||||
|
|
||||||
|
it("different agents get different namespaces", () => {
|
||||||
|
const alphaId = effectiveUserId(base, "agent:alpha:uuid-a");
|
||||||
|
const betaId = effectiveUserId(base, "agent:beta:uuid-b");
|
||||||
|
expect(alphaId).not.toBe(betaId);
|
||||||
|
expect(alphaId).toBe("user-42:agent:alpha");
|
||||||
|
expect(betaId).toBe("user-42:agent:beta");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("same agent across sessions yields the same namespace", () => {
|
||||||
|
const s1 = effectiveUserId(base, "agent:alpha:session-1");
|
||||||
|
const s2 = effectiveUserId(base, "agent:alpha:session-2");
|
||||||
|
expect(s1).toBe(s2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("main session shares the base namespace (no isolation)", () => {
|
||||||
|
const mainId = effectiveUserId(base, "agent:main:uuid-m");
|
||||||
|
expect(mainId).toBe(base);
|
||||||
|
});
|
||||||
|
});
|
||||||
+116
-26
@@ -10,6 +10,8 @@
|
|||||||
* - Short-term (session-scoped) and long-term (user-scoped) memory
|
* - Short-term (session-scoped) and long-term (user-scoped) memory
|
||||||
* - Auto-recall: injects relevant memories (both scopes) before each agent turn
|
* - Auto-recall: injects relevant memories (both scopes) before each agent turn
|
||||||
* - Auto-capture: stores key facts scoped to the current session after each agent turn
|
* - Auto-capture: stores key facts scoped to the current session after each agent turn
|
||||||
|
* - Per-agent isolation: multi-agent setups write/read from separate userId namespaces
|
||||||
|
* automatically via sessionKey routing (zero breaking changes for single-agent setups)
|
||||||
* - CLI: openclaw mem0 search, openclaw mem0 stats
|
* - CLI: openclaw mem0 search, openclaw mem0 stats
|
||||||
* - Dual mode: platform or open-source (self-hosted)
|
* - Dual mode: platform or open-source (self-hosted)
|
||||||
*/
|
*/
|
||||||
@@ -605,6 +607,50 @@ function categoriesToArray(
|
|||||||
return Object.entries(cats).map(([key, value]) => ({ [key]: value }));
|
return Object.entries(cats).map(([key, value]) => ({ [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Per-agent isolation helpers (exported for testability)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an agent ID from a session key following the pattern `agent:<agentId>:<uuid>`.
|
||||||
|
* Returns undefined for non-agent sessions, the "main" sentinel, or malformed keys.
|
||||||
|
*/
|
||||||
|
export function extractAgentId(sessionKey: string | undefined): string | undefined {
|
||||||
|
if (!sessionKey) return undefined;
|
||||||
|
const match = sessionKey.match(/^agent:([^:]+):/);
|
||||||
|
const agentId = match?.[1];
|
||||||
|
// "main" is the primary session — fall back to configured userId
|
||||||
|
if (!agentId || agentId === "main") return undefined;
|
||||||
|
return agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the effective user_id from a session key, namespacing per-agent.
|
||||||
|
* Falls back to baseUserId when the session is not agent-scoped.
|
||||||
|
*/
|
||||||
|
export function effectiveUserId(baseUserId: string, sessionKey?: string): string {
|
||||||
|
const agentId = extractAgentId(sessionKey);
|
||||||
|
return agentId ? `${baseUserId}:agent:${agentId}` : baseUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a user_id for an explicit agentId (e.g. from tool params). */
|
||||||
|
export function agentUserId(baseUserId: string, agentId: string): string {
|
||||||
|
return `${baseUserId}:agent:${agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve user_id with priority: explicit agentId > explicit userId > session-derived > configured.
|
||||||
|
*/
|
||||||
|
export function resolveUserId(
|
||||||
|
baseUserId: string,
|
||||||
|
opts: { agentId?: string; userId?: string },
|
||||||
|
currentSessionId?: string,
|
||||||
|
): string {
|
||||||
|
if (opts.agentId) return agentUserId(baseUserId, opts.agentId);
|
||||||
|
if (opts.userId) return opts.userId;
|
||||||
|
return effectiveUserId(baseUserId, currentSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Plugin Definition
|
// Plugin Definition
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -624,14 +670,23 @@ const memoryPlugin = {
|
|||||||
// Track current session ID for tool-level session scoping
|
// Track current session ID for tool-level session scoping
|
||||||
let currentSessionId: string | undefined;
|
let currentSessionId: string | undefined;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Per-agent isolation helpers (thin wrappers around exported functions)
|
||||||
|
// ========================================================================
|
||||||
|
const _effectiveUserId = (sessionKey?: string) =>
|
||||||
|
effectiveUserId(cfg.userId, sessionKey);
|
||||||
|
const _agentUserId = (id: string) => agentUserId(cfg.userId, id);
|
||||||
|
const _resolveUserId = (opts: { agentId?: string; userId?: string }) =>
|
||||||
|
resolveUserId(cfg.userId, opts, currentSessionId);
|
||||||
|
|
||||||
api.logger.info(
|
api.logger.info(
|
||||||
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
|
`openclaw-mem0: registered (mode: ${cfg.mode}, user: ${cfg.userId}, graph: ${cfg.enableGraph}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper: build add options
|
// Helper: build add options
|
||||||
function buildAddOptions(userIdOverride?: string, runId?: string): AddOptions {
|
function buildAddOptions(userIdOverride?: string, runId?: string, sessionKey?: string): AddOptions {
|
||||||
const opts: AddOptions = {
|
const opts: AddOptions = {
|
||||||
user_id: userIdOverride || cfg.userId,
|
user_id: userIdOverride || _effectiveUserId(sessionKey),
|
||||||
source: "OPENCLAW",
|
source: "OPENCLAW",
|
||||||
};
|
};
|
||||||
if (runId) opts.run_id = runId;
|
if (runId) opts.run_id = runId;
|
||||||
@@ -649,9 +704,10 @@ const memoryPlugin = {
|
|||||||
userIdOverride?: string,
|
userIdOverride?: string,
|
||||||
limit?: number,
|
limit?: number,
|
||||||
runId?: string,
|
runId?: string,
|
||||||
|
sessionKey?: string,
|
||||||
): SearchOptions {
|
): SearchOptions {
|
||||||
const opts: SearchOptions = {
|
const opts: SearchOptions = {
|
||||||
user_id: userIdOverride || cfg.userId,
|
user_id: userIdOverride || _effectiveUserId(sessionKey),
|
||||||
top_k: limit ?? cfg.topK,
|
top_k: limit ?? cfg.topK,
|
||||||
limit: limit ?? cfg.topK,
|
limit: limit ?? cfg.topK,
|
||||||
threshold: cfg.searchThreshold,
|
threshold: cfg.searchThreshold,
|
||||||
@@ -686,6 +742,12 @@ const memoryPlugin = {
|
|||||||
"User ID to scope search (default: configured userId)",
|
"User ID to scope search (default: configured userId)",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
agentId: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description:
|
||||||
|
"Agent ID to search memories for a specific agent (e.g. \"researcher\"). Overrides userId.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
scope: Type.Optional(
|
scope: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("session"),
|
Type.Literal("session"),
|
||||||
@@ -698,39 +760,41 @@ const memoryPlugin = {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { query, limit, userId, scope = "all" } = params as {
|
const { query, limit, userId, agentId, scope = "all" } = params as {
|
||||||
query: string;
|
query: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
agentId?: string;
|
||||||
scope?: "session" | "long-term" | "all";
|
scope?: "session" | "long-term" | "all";
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let results: MemoryItem[] = [];
|
let results: MemoryItem[] = [];
|
||||||
|
const uid = _resolveUserId({ agentId, userId });
|
||||||
|
|
||||||
if (scope === "session") {
|
if (scope === "session") {
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
results = await provider.search(
|
results = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(userId, limit, currentSessionId),
|
buildSearchOptions(uid, limit, currentSessionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (scope === "long-term") {
|
} else if (scope === "long-term") {
|
||||||
results = await provider.search(
|
results = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(userId, limit),
|
buildSearchOptions(uid, limit),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// "all" — search both scopes and combine
|
// "all" — search both scopes and combine
|
||||||
const longTermResults = await provider.search(
|
const longTermResults = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(userId, limit),
|
buildSearchOptions(uid, limit),
|
||||||
);
|
);
|
||||||
let sessionResults: MemoryItem[] = [];
|
let sessionResults: MemoryItem[] = [];
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionResults = await provider.search(
|
sessionResults = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(userId, limit, currentSessionId),
|
buildSearchOptions(uid, limit, currentSessionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Deduplicate by ID, preferring long-term
|
// Deduplicate by ID, preferring long-term
|
||||||
@@ -803,6 +867,12 @@ const memoryPlugin = {
|
|||||||
description: "User ID to scope this memory",
|
description: "User ID to scope this memory",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
agentId: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description:
|
||||||
|
"Agent ID to store memory under a specific agent's namespace (e.g. \"researcher\"). Overrides userId.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
metadata: Type.Optional(
|
metadata: Type.Optional(
|
||||||
Type.Record(Type.String(), Type.Unknown(), {
|
Type.Record(Type.String(), Type.Unknown(), {
|
||||||
description: "Optional metadata to attach to this memory",
|
description: "Optional metadata to attach to this memory",
|
||||||
@@ -816,18 +886,20 @@ const memoryPlugin = {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { text, userId, longTerm = true } = params as {
|
const { text, userId, agentId, longTerm = true } = params as {
|
||||||
text: string;
|
text: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
agentId?: string;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
longTerm?: boolean;
|
longTerm?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const uid = _resolveUserId({ agentId, userId });
|
||||||
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
|
const runId = !longTerm && currentSessionId ? currentSessionId : undefined;
|
||||||
const result = await provider.add(
|
const result = await provider.add(
|
||||||
[{ role: "user", content: text }],
|
[{ role: "user", content: text }],
|
||||||
buildAddOptions(userId, runId),
|
buildAddOptions(uid, runId, currentSessionId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const added =
|
const added =
|
||||||
@@ -919,7 +991,7 @@ const memoryPlugin = {
|
|||||||
name: "memory_list",
|
name: "memory_list",
|
||||||
label: "Memory List",
|
label: "Memory List",
|
||||||
description:
|
description:
|
||||||
"List all stored memories for a user. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
|
"List all stored memories for a user or agent. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
userId: Type.Optional(
|
userId: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
@@ -927,6 +999,12 @@ const memoryPlugin = {
|
|||||||
"User ID to list memories for (default: configured userId)",
|
"User ID to list memories for (default: configured userId)",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
agentId: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description:
|
||||||
|
"Agent ID to list memories for a specific agent (e.g. \"researcher\"). Overrides userId.",
|
||||||
|
}),
|
||||||
|
),
|
||||||
scope: Type.Optional(
|
scope: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("session"),
|
Type.Literal("session"),
|
||||||
@@ -939,11 +1017,11 @@ const memoryPlugin = {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { userId, scope = "all" } = params as { userId?: string; scope?: "session" | "long-term" | "all" };
|
const { userId, agentId, scope = "all" } = params as { userId?: string; agentId?: string; scope?: "session" | "long-term" | "all" };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let memories: MemoryItem[] = [];
|
let memories: MemoryItem[] = [];
|
||||||
const uid = userId || cfg.userId;
|
const uid = _resolveUserId({ agentId, userId });
|
||||||
|
|
||||||
if (scope === "session") {
|
if (scope === "session") {
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
@@ -1026,7 +1104,7 @@ const memoryPlugin = {
|
|||||||
name: "memory_forget",
|
name: "memory_forget",
|
||||||
label: "Memory Forget",
|
label: "Memory Forget",
|
||||||
description:
|
description:
|
||||||
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. GDPR-compliant.",
|
"Delete memories from Mem0. Provide a specific memoryId to delete directly, or a query to search and delete matching memories. Supports agent-scoped deletion. GDPR-compliant.",
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
query: Type.Optional(
|
query: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
@@ -1036,11 +1114,18 @@ const memoryPlugin = {
|
|||||||
memoryId: Type.Optional(
|
memoryId: Type.Optional(
|
||||||
Type.String({ description: "Specific memory ID to delete" }),
|
Type.String({ description: "Specific memory ID to delete" }),
|
||||||
),
|
),
|
||||||
|
agentId: Type.Optional(
|
||||||
|
Type.String({
|
||||||
|
description:
|
||||||
|
"Agent ID to scope deletion to a specific agent's memories (e.g. \"researcher\").",
|
||||||
|
}),
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
async execute(_toolCallId, params) {
|
async execute(_toolCallId, params) {
|
||||||
const { query, memoryId } = params as {
|
const { query, memoryId, agentId } = params as {
|
||||||
query?: string;
|
query?: string;
|
||||||
memoryId?: string;
|
memoryId?: string;
|
||||||
|
agentId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1055,9 +1140,10 @@ const memoryPlugin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
|
const uid = _resolveUserId({ agentId });
|
||||||
const results = await provider.search(
|
const results = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(undefined, 5),
|
buildSearchOptions(uid, 5),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!results || results.length === 0) {
|
if (!results || results.length === 0) {
|
||||||
@@ -1148,10 +1234,12 @@ const memoryPlugin = {
|
|||||||
.argument("<query>", "Search query")
|
.argument("<query>", "Search query")
|
||||||
.option("--limit <n>", "Max results", String(cfg.topK))
|
.option("--limit <n>", "Max results", String(cfg.topK))
|
||||||
.option("--scope <scope>", 'Memory scope: "session", "long-term", or "all"', "all")
|
.option("--scope <scope>", 'Memory scope: "session", "long-term", or "all"', "all")
|
||||||
.action(async (query: string, opts: { limit: string; scope: string }) => {
|
.option("--agent <agentId>", "Search a specific agent's memory namespace")
|
||||||
|
.action(async (query: string, opts: { limit: string; scope: string; agent?: string }) => {
|
||||||
try {
|
try {
|
||||||
const limit = parseInt(opts.limit, 10);
|
const limit = parseInt(opts.limit, 10);
|
||||||
const scope = opts.scope as "session" | "long-term" | "all";
|
const scope = opts.scope as "session" | "long-term" | "all";
|
||||||
|
const uid = opts.agent ? _agentUserId(opts.agent) : _effectiveUserId(currentSessionId);
|
||||||
|
|
||||||
let allResults: MemoryItem[] = [];
|
let allResults: MemoryItem[] = [];
|
||||||
|
|
||||||
@@ -1159,7 +1247,7 @@ const memoryPlugin = {
|
|||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
const sessionResults = await provider.search(
|
const sessionResults = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(undefined, limit, currentSessionId),
|
buildSearchOptions(uid, limit, currentSessionId),
|
||||||
);
|
);
|
||||||
if (sessionResults?.length) {
|
if (sessionResults?.length) {
|
||||||
allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const })));
|
allResults.push(...sessionResults.map((r) => ({ ...r, _scope: "session" as const })));
|
||||||
@@ -1173,7 +1261,7 @@ const memoryPlugin = {
|
|||||||
if (scope === "long-term" || scope === "all") {
|
if (scope === "long-term" || scope === "all") {
|
||||||
const longTermResults = await provider.search(
|
const longTermResults = await provider.search(
|
||||||
query,
|
query,
|
||||||
buildSearchOptions(undefined, limit),
|
buildSearchOptions(uid, limit),
|
||||||
);
|
);
|
||||||
if (longTermResults?.length) {
|
if (longTermResults?.length) {
|
||||||
allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const })));
|
allResults.push(...longTermResults.map((r) => ({ ...r, _scope: "long-term" as const })));
|
||||||
@@ -1212,14 +1300,16 @@ const memoryPlugin = {
|
|||||||
mem0
|
mem0
|
||||||
.command("stats")
|
.command("stats")
|
||||||
.description("Show memory statistics from Mem0")
|
.description("Show memory statistics from Mem0")
|
||||||
.action(async () => {
|
.option("--agent <agentId>", "Show stats for a specific agent")
|
||||||
|
.action(async (opts: { agent?: string }) => {
|
||||||
try {
|
try {
|
||||||
|
const uid = opts.agent ? _agentUserId(opts.agent) : cfg.userId;
|
||||||
const memories = await provider.getAll({
|
const memories = await provider.getAll({
|
||||||
user_id: cfg.userId,
|
user_id: uid,
|
||||||
source: "OPENCLAW",
|
source: "OPENCLAW",
|
||||||
});
|
});
|
||||||
console.log(`Mode: ${cfg.mode}`);
|
console.log(`Mode: ${cfg.mode}`);
|
||||||
console.log(`User: ${cfg.userId}`);
|
console.log(`User: ${uid}${opts.agent ? ` (agent: ${opts.agent})` : ""}`);
|
||||||
console.log(
|
console.log(
|
||||||
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
|
`Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
|
||||||
);
|
);
|
||||||
@@ -1249,10 +1339,10 @@ const memoryPlugin = {
|
|||||||
if (sessionId) currentSessionId = sessionId;
|
if (sessionId) currentSessionId = sessionId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Search long-term memories (user-scoped)
|
// Search long-term memories (user-scoped, isolated per agent)
|
||||||
const longTermResults = await provider.search(
|
const longTermResults = await provider.search(
|
||||||
event.prompt,
|
event.prompt,
|
||||||
buildSearchOptions(),
|
buildSearchOptions(undefined, undefined, undefined, sessionId),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Search session memories (session-scoped) if we have a session ID
|
// Search session memories (session-scoped) if we have a session ID
|
||||||
@@ -1260,7 +1350,7 @@ const memoryPlugin = {
|
|||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
sessionResults = await provider.search(
|
sessionResults = await provider.search(
|
||||||
event.prompt,
|
event.prompt,
|
||||||
buildSearchOptions(undefined, undefined, currentSessionId),
|
buildSearchOptions(undefined, undefined, currentSessionId, sessionId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1365,7 +1455,7 @@ const memoryPlugin = {
|
|||||||
|
|
||||||
if (formattedMessages.length === 0) return;
|
if (formattedMessages.length === 0) return;
|
||||||
|
|
||||||
const addOpts = buildAddOptions(undefined, currentSessionId);
|
const addOpts = buildAddOptions(undefined, currentSessionId, sessionId);
|
||||||
const result = await provider.add(
|
const result = await provider.add(
|
||||||
formattedMessages,
|
formattedMessages,
|
||||||
addOpts,
|
addOpts,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
"mem0",
|
"mem0",
|
||||||
"long-term-memory"
|
"long-term-memory"
|
||||||
],
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "0.34.47",
|
"@sinclair/typebox": "0.34.47",
|
||||||
"mem0ai": "^2.2.1"
|
"mem0ai": "^2.2.1"
|
||||||
@@ -19,5 +22,8 @@
|
|||||||
"extensions": [
|
"extensions": [
|
||||||
"./index.ts"
|
"./index.ts"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user