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
24 changes: 24 additions & 0 deletions examples/openclaw-memory-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Use [OpenViking](https://github.com/volcengine/OpenViking) as the long-term memo
- [Configuration Reference](#configuration-reference)
- [Daily Usage](#daily-usage)
- [Web Console (Visualization)](#web-console-visualization)
- [Multi-Agent Memory Isolation](#multi-agent-memory-isolation)
- [Troubleshooting](#troubleshooting)
- [Uninstallation](#uninstallation)

Expand Down Expand Up @@ -411,6 +412,29 @@ Open http://127.0.0.1:8020 in your browser.

---

## Multi-Agent Memory Isolation

Previously, all agents on the same OpenClaw instance shared a single memory namespace — memories stored by one agent were visible to every other agent. The plugin now supports **per-agent memory isolation**: each agent's memories are automatically namespaced by its agent ID, so agents no longer see each other's memories.

**This is enabled by default.** No extra configuration is needed — simply leave the `agentId` config empty and the plugin will use the agent ID provided by the OpenClaw host.

| `agentId` config | Behavior |
|---|---|
| **Not set** (default, recommended) | Each agent gets its own isolated memory namespace. The plugin reads the agent ID from the OpenClaw host automatically. |
| **Set to a fixed value** (e.g. `"default"`) | All agents using this value share the same memory namespace (the old behavior). |

> **Backward compatibility:** OpenClaw's default primary agent ID is `main`. For compatibility with previous versions (where all memories were stored under `default`), the plugin maps `main` to the `default` namespace — so existing memories remain accessible after upgrading. Other agents get their own isolated namespace based on their agent ID.

### Reverting to Shared Memory

If you need all agents to share the same memories (the previous behavior), set a fixed `agentId`:

```bash
openclaw config set plugins.entries.memory-openviking.config.agentId "default"
```

---

## Troubleshooting

### Common Issues
Expand Down
106 changes: 48 additions & 58 deletions examples/openclaw-memory-plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,46 +58,25 @@ export function isMemoryUri(uri: string): boolean {
}

export class OpenVikingClient {
private resolvedSpaceByScope: Partial<Record<ScopeName, string>> = {};
private runtimeIdentity: RuntimeIdentity | null = null;
private readonly resolvedSpaceCache = new Map<string, string>();
private userId: string | null = null;

constructor(
private readonly baseUrl: string,
private readonly apiKey: string,
private agentId: string,
private readonly timeoutMs: number,
) {}

/**
* Dynamically switch the agent identity for multi-agent memory isolation.
* When a shared client serves multiple agents (e.g. in OpenClaw multi-agent
* gateway), call this before each agent's recall/capture to route memories
* to the correct agent_space = md5(user_id + agent_id)[:12].
* Clears cached space resolution so the next request re-derives agent_space.
*/
setAgentId(newAgentId: string): void {
if (newAgentId && newAgentId !== this.agentId) {
this.agentId = newAgentId;
// Clear cached identity and spaces — they depend on agentId
this.runtimeIdentity = null;
this.resolvedSpaceByScope = {};
}
}

getAgentId(): string {
return this.agentId;
}

private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
private async request<T>(path: string, init: RequestInit = {}, agentId?: string): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const headers = new Headers(init.headers ?? {});
if (this.apiKey) {
headers.set("X-API-Key", this.apiKey);
}
if (this.agentId) {
headers.set("X-OpenViking-Agent", this.agentId);
if (agentId) {
headers.set("X-OpenViking-Agent", agentId);
}
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
Expand Down Expand Up @@ -131,72 +110,77 @@ export class OpenVikingClient {
await this.request<{ status: string }>("/health");
}

private async ls(uri: string): Promise<Array<Record<string, unknown>>> {
private async ls(uri: string, agentId: string): Promise<Array<Record<string, unknown>>> {
return this.request<Array<Record<string, unknown>>>(
`/api/v1/fs/ls?uri=${encodeURIComponent(uri)}&output=original`,
{},
agentId
);
}

private async getRuntimeIdentity(): Promise<RuntimeIdentity> {
if (this.runtimeIdentity) {
return this.runtimeIdentity;
private async getUserId(): Promise<string> {
if (this.userId) {
return this.userId;
}
const fallback: RuntimeIdentity = { userId: "default", agentId: this.agentId || "default" };
try {
const status = await this.request<{ user?: unknown }>("/api/v1/system/status");
const userId =
this.userId =
typeof status.user === "string" && status.user.trim() ? status.user.trim() : "default";
this.runtimeIdentity = { userId, agentId: this.agentId || "default" };
return this.runtimeIdentity;
} catch {
this.runtimeIdentity = fallback;
return fallback;
this.userId = "default";
}
return this.userId;
}

private async getRuntimeIdentity(agentId: string): Promise<RuntimeIdentity> {
const userId = await this.getUserId();
return { userId, agentId: agentId || "default" };
}

private async resolveScopeSpace(scope: ScopeName): Promise<string> {
const cached = this.resolvedSpaceByScope[scope];
private async resolveScopeSpace(scope: ScopeName, agentId: string): Promise<string> {
const cacheKey = `${scope}:${agentId}`;
const cached = this.resolvedSpaceCache.get(cacheKey);
if (cached) {
return cached;
}

const identity = await this.getRuntimeIdentity();
const identity = await this.getRuntimeIdentity(agentId);
const fallbackSpace =
scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`);
const reservedDirs = scope === "user" ? USER_STRUCTURE_DIRS : AGENT_STRUCTURE_DIRS;
const preferredSpace =
scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`);

try {
const entries = await this.ls(`viking://${scope}`);
const entries = await this.ls(`viking://${scope}`, agentId);
const spaces = entries
.filter((entry) => entry?.isDir === true)
.map((entry) => (typeof entry.name === "string" ? entry.name.trim() : ""))
.filter((name) => name && !name.startsWith(".") && !reservedDirs.has(name));

if (spaces.length > 0) {
if (spaces.includes(preferredSpace)) {
this.resolvedSpaceByScope[scope] = preferredSpace;
this.resolvedSpaceCache.set(cacheKey, preferredSpace);
return preferredSpace;
}
if (scope === "user" && spaces.includes("default")) {
this.resolvedSpaceByScope[scope] = "default";
this.resolvedSpaceCache.set(cacheKey, "default");
return "default";
}
if (spaces.length === 1) {
this.resolvedSpaceByScope[scope] = spaces[0]!;
this.resolvedSpaceCache.set(cacheKey, spaces[0]!);
return spaces[0]!;
}
}
} catch {
// Fall back to identity-derived space when listing fails.
}

this.resolvedSpaceByScope[scope] = fallbackSpace;
this.resolvedSpaceCache.set(cacheKey, fallbackSpace);
return fallbackSpace;
}

private async normalizeTargetUri(targetUri: string): Promise<string> {
private async normalizeTargetUri(targetUri: string, agentId: string): Promise<string> {
const trimmed = targetUri.trim().replace(/\/+$/, "");
const match = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/);
if (!match) {
Expand All @@ -217,7 +201,7 @@ export class OpenVikingClient {
return trimmed;
}

const space = await this.resolveScopeSpace(scope);
const space = await this.resolveScopeSpace(scope, agentId);
return `viking://${scope}/${space}/${parts.join("/")}`;
}

Expand All @@ -227,9 +211,10 @@ export class OpenVikingClient {
targetUri: string;
limit: number;
scoreThreshold?: number;
agentId: string;
},
): Promise<FindResult> {
const normalizedTargetUri = await this.normalizeTargetUri(options.targetUri);
const normalizedTargetUri = await this.normalizeTargetUri(options.targetUri, options.agentId);
const body = {
query,
target_uri: normalizedTargetUri,
Expand All @@ -239,55 +224,60 @@ export class OpenVikingClient {
return this.request<FindResult>("/api/v1/search/find", {
method: "POST",
body: JSON.stringify(body),
});
}, options.agentId);
}

async read(uri: string): Promise<string> {
async read(uri: string, agentId: string): Promise<string> {
return this.request<string>(
`/api/v1/content/read?uri=${encodeURIComponent(uri)}`,
{},
agentId,
);
}

async createSession(): Promise<string> {
async createSession(agentId: string): Promise<string> {
const result = await this.request<{ session_id: string }>("/api/v1/sessions", {
method: "POST",
body: JSON.stringify({}),
});
}, agentId);
return result.session_id;
}

async addSessionMessage(sessionId: string, role: string, content: string): Promise<void> {
async addSessionMessage(sessionId: string, role: string, content: string, agentId: string): Promise<void> {
await this.request<{ session_id: string }>(
`/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`,
{
method: "POST",
body: JSON.stringify({ role, content }),
},
agentId,
);
}

/** GET session so server loads messages from storage before extract (workaround for AGFS visibility). */
async getSession(sessionId: string): Promise<{ message_count?: number }> {
async getSession(sessionId: string, agentId: string): Promise<{ message_count?: number }> {
return this.request<{ message_count?: number }>(
`/api/v1/sessions/${encodeURIComponent(sessionId)}`,
{ method: "GET" },
agentId,
);
}

async extractSessionMemories(sessionId: string): Promise<Array<Record<string, unknown>>> {
async extractSessionMemories(sessionId: string, agentId: string): Promise<Array<Record<string, unknown>>> {
return this.request<Array<Record<string, unknown>>>(
`/api/v1/sessions/${encodeURIComponent(sessionId)}/extract`,
{ method: "POST", body: JSON.stringify({}) },
agentId,
);
}

async deleteSession(sessionId: string): Promise<void> {
await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
async deleteSession(sessionId: string, agentId: string): Promise<void> {
await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }, agentId);
}

async deleteUri(uri: string): Promise<void> {
async deleteUri(uri: string, agentId: string): Promise<void> {
await this.request(`/api/v1/fs?uri=${encodeURIComponent(uri)}&recursive=false`, {
method: "DELETE",
});
}, agentId);
}
}
11 changes: 4 additions & 7 deletions examples/openclaw-memory-plugin/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,10 @@ const DEFAULT_INGEST_REPLY_ASSIST_MIN_SPEAKER_TURNS = 2;
const DEFAULT_INGEST_REPLY_ASSIST_MIN_CHARS = 120;
const DEFAULT_LOCAL_CONFIG_PATH = join(homedir(), ".openviking", "ov.conf");

const DEFAULT_AGENT_ID = "default";

function resolveAgentId(configured: unknown): string {
function resolveAgentId(configured: unknown): string | undefined {
if (typeof configured === "string" && configured.trim()) {
return configured.trim();
}
return DEFAULT_AGENT_ID;
}

function resolveEnvVars(value: string): string {
Expand Down Expand Up @@ -87,7 +84,7 @@ function resolveDefaultBaseUrl(): string {
}

export const memoryOpenVikingConfigSchema = {
parse(value: unknown): Required<MemoryOpenVikingConfig> {
parse(value: unknown): Required<Omit<MemoryOpenVikingConfig, "agentId">> & Pick<MemoryOpenVikingConfig, "agentId"> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
value = {};
}
Expand Down Expand Up @@ -208,8 +205,8 @@ export const memoryOpenVikingConfigSchema = {
},
agentId: {
label: "Agent ID",
placeholder: "auto-generated",
help: "Identifies this agent to OpenViking (sent as X-OpenViking-Agent header). Defaults to \"default\" if not set.",
placeholder: "default",
help: "Leave empty for per-agent memory isolation (recommended). The host-provided agent ID is used to namespace memories; \"main\" maps to \"default\" for backward compatibility. Set a fixed value (e.g. \"default\") to share one namespace across all agents.",
},
apiKey: {
label: "OpenViking API Key",
Expand Down
Loading