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: 22 additions & 2 deletions examples/openclaw-memory-plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,36 @@ export function isMemoryUri(uri: string): boolean {
}

export class OpenVikingClient {
private readonly resolvedSpaceByScope: Partial<Record<ScopeName, string>> = {};
private resolvedSpaceByScope: Partial<Record<ScopeName, string>> = {};
private runtimeIdentity: RuntimeIdentity | null = null;

constructor(
private readonly baseUrl: string,
private readonly apiKey: string,
private readonly agentId: 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> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
Expand Down
14 changes: 14 additions & 0 deletions examples/openclaw-memory-plugin/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type MemoryOpenVikingConfig = {
timeoutMs?: number;
autoCapture?: boolean;
captureMode?: "semantic" | "keyword";
/** Minimum sanitized text length (chars) required to trigger auto-capture. Default 50. */
captureMinLength?: number;
captureMaxLength?: number;
autoRecall?: boolean;
recallLimit?: number;
Expand All @@ -30,6 +32,7 @@ const DEFAULT_PORT = 1933;
const DEFAULT_TARGET_URI = "viking://user/memories";
const DEFAULT_TIMEOUT_MS = 15000;
const DEFAULT_CAPTURE_MODE = "semantic";
const DEFAULT_CAPTURE_MIN_LENGTH = 50;
const DEFAULT_CAPTURE_MAX_LENGTH = 24000;
const DEFAULT_RECALL_LIMIT = 6;
const DEFAULT_RECALL_SCORE_THRESHOLD = 0.01;
Expand Down Expand Up @@ -105,6 +108,7 @@ export const memoryOpenVikingConfigSchema = {
"timeoutMs",
"autoCapture",
"captureMode",
"captureMinLength",
"captureMaxLength",
"autoRecall",
"recallLimit",
Expand Down Expand Up @@ -153,6 +157,10 @@ export const memoryOpenVikingConfigSchema = {
timeoutMs: Math.max(1000, Math.floor(toNumber(cfg.timeoutMs, DEFAULT_TIMEOUT_MS))),
autoCapture: cfg.autoCapture !== false,
captureMode: captureMode ?? DEFAULT_CAPTURE_MODE,
captureMinLength: Math.max(
1,
Math.min(1000, Math.floor(toNumber(cfg.captureMinLength, DEFAULT_CAPTURE_MIN_LENGTH))),
),
captureMaxLength: Math.max(
200,
Math.min(200_000, Math.floor(toNumber(cfg.captureMaxLength, DEFAULT_CAPTURE_MAX_LENGTH))),
Expand Down Expand Up @@ -237,6 +245,12 @@ export const memoryOpenVikingConfigSchema = {
advanced: true,
help: '"semantic" captures all eligible user text and relies on OpenViking extraction; "keyword" uses trigger regex first.',
},
captureMinLength: {
label: "Capture Min Length",
placeholder: String(DEFAULT_CAPTURE_MIN_LENGTH),
advanced: true,
help: "Minimum sanitized text length (chars) required to trigger auto-capture. Shorter messages are skipped to save VLM tokens.",
},
captureMaxLength: {
label: "Capture Max Length",
placeholder: String(DEFAULT_CAPTURE_MAX_LENGTH),
Expand Down
22 changes: 19 additions & 3 deletions examples/openclaw-memory-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,16 @@ const memoryPlugin = {
);

if (cfg.autoRecall || cfg.ingestReplyAssist) {
api.on("before_agent_start", async (event: { messages?: unknown[]; prompt: string }) => {
api.on("before_agent_start", async (event: { messages?: unknown[]; prompt: string }, ctx?: { agentId?: string }) => {
// Dynamically switch agent identity for multi-agent memory isolation.
// In multi-agent gateway deployments, the hook context carries the current
// agent's ID so we route memory operations to the correct agent_space.
const hookAgentId = ctx?.agentId;
if (hookAgentId) {
const client = await getClient();
client.setAgentId(hookAgentId);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug] setAgentId() mutates shared OpenVikingClient state (agentId, resolvedSpaceByScope, runtimeIdentity). In concurrent multi-agent scenarios, async handlers interleave at await points (e.g., client.find()normalizeTargetUriresolveScopeSpace). For example:

  1. Agent A's handler calls setAgentId("A"), then await client.find() (HTTP in-flight)
  2. Agent B's handler fires, calls setAgentId("B"), clearing cached resolvedSpaceByScope
  3. Agent A's find() resumes — resolveScopeSpace now uses this.agentId = "B", deriving the wrong agent_space

This causes memories to be stored in / retrieved from the wrong agent's space. The same issue exists in the agent_end handler below (line 491).

Consider either:

  • Creating a per-agent client instance (e.g., clone with agent-specific ID) instead of mutating shared state
  • Passing agentId as a parameter through find()/request() so each call is self-contained

api.logger.info?.(`memory-openviking: switched to agentId=${hookAgentId} for recall`);
}
const queryText = extractLatestUserText(event.messages) || event.prompt.trim();
if (!queryText) {
return;
Expand Down Expand Up @@ -474,7 +483,14 @@ const memoryPlugin = {
if (cfg.autoCapture) {
let lastProcessedMsgCount = 0;

api.on("agent_end", async (event: { success?: boolean; messages?: unknown[] }) => {
api.on("agent_end", async (event: { success?: boolean; messages?: unknown[] }, ctx?: { agentId?: string }) => {
// Dynamically switch agent identity for multi-agent memory isolation
const hookAgentId = ctx?.agentId;
if (hookAgentId) {
const client = await getClient();
client.setAgentId(hookAgentId);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug] lastProcessedMsgCount (line 484) is a closure variable shared across all agents. In multi-agent setups each agent has its own message history, but this counter tracks a single global position. After Agent A's agent_end processes 10 messages (lastProcessedMsgCount = 10), Agent B's agent_end calls extractNewTurnTexts(messages, 10) — if B's message array has fewer than 10 elements, no new texts are extracted and B's memories are never captured.

Consider using a Map<string, number> keyed by agentId to track per-agent message positions:

const lastProcessedMsgCountByAgent = new Map<string, number>();
// in handler:
const agentKey = hookAgentId ?? cfg.agentId;
const lastCount = lastProcessedMsgCountByAgent.get(agentKey) ?? 0;
// ... after processing:
lastProcessedMsgCountByAgent.set(agentKey, messages.length);

api.logger.info?.(`memory-openviking: switched to agentId=${hookAgentId} for capture`);
}
if (!event.success || !event.messages || event.messages.length === 0) {
api.logger.info(
`memory-openviking: auto-capture skipped (success=${String(event.success)}, messages=${event.messages?.length ?? 0})`,
Expand All @@ -495,7 +511,7 @@ const memoryPlugin = {
const turnText = newTexts.join("\n");

// 对合并文本做 capture decision(主要检查长度和命令过滤)
const decision = getCaptureDecision(turnText, cfg.captureMode, cfg.captureMaxLength);
const decision = getCaptureDecision(turnText, cfg.captureMode, cfg.captureMinLength, cfg.captureMaxLength);
const preview = turnText.length > 80 ? `${turnText.slice(0, 80)}...` : turnText;
api.logger.info(
`memory-openviking: capture-check shouldCapture=${String(decision.shouldCapture)} reason=${decision.reason} newMsgCount=${newCount} text="${preview}"`,
Expand Down
4 changes: 2 additions & 2 deletions examples/openclaw-memory-plugin/text-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export function pickRecentUniqueTexts(texts: string[], limit: number): string[]
return picked.reverse();
}

export function getCaptureDecision(text: string, mode: CaptureMode, captureMaxLength: number): {
export function getCaptureDecision(text: string, mode: CaptureMode, captureMinLength: number, captureMaxLength: number): {
shouldCapture: boolean;
reason: string;
normalizedText: string;
Expand All @@ -223,7 +223,7 @@ export function getCaptureDecision(text: string, mode: CaptureMode, captureMaxLe
}

const compactText = normalizedText.replace(/\s+/g, "");
const minLength = resolveCaptureMinLength(compactText);
const minLength = Math.max(resolveCaptureMinLength(compactText), captureMinLength);
if (compactText.length < minLength || normalizedText.length > captureMaxLength) {
return {
shouldCapture: false,
Expand Down
Loading