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
104 changes: 69 additions & 35 deletions examples/openclaw-memory-plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,53 +47,87 @@ 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,
private readonly maxRetries: number = 0,
) {}

/**
* 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);
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 (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
let lastError: Error | null = null;
const maxAttempts = 1 + Math.max(0, this.maxRetries);

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
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 (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}

const response = await fetch(`${this.baseUrl}${path}`, {
...init,
headers,
signal: controller.signal,
});

const payload = (await response.json().catch(() => ({}))) as {
status?: string;
result?: T;
error?: { code?: string; message?: string };
};

if (!response.ok || payload.status === "error") {
const code = payload.error?.code ? ` [${payload.error.code}]` : "";
const message = payload.error?.message ?? `HTTP ${response.status}`;
throw new Error(`OpenViking request failed${code}: ${message}`);
}
const response = await fetch(`${this.baseUrl}${path}`, {
...init,
headers,
signal: controller.signal,
});

const payload = (await response.json().catch(() => ({}))) as {
status?: string;
result?: T;
error?: { code?: string; message?: string };
};

if (!response.ok || payload.status === "error") {
const code = payload.error?.code ? ` [${payload.error.code}]` : "";
const message = payload.error?.message ?? `HTTP ${response.status}`;
throw new Error(`OpenViking request failed${code}: ${message}`);
}

return (payload.result ?? payload) as T;
} finally {
clearTimeout(timer);
return (payload.result ?? payload) as T;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < maxAttempts) {
const delay = Math.min(1000 * 2 ** (attempt - 1), 5000);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
} finally {
clearTimeout(timer);
}
}
throw lastError || new Error("Request failed after retries");
}

async healthCheck(): Promise<void> {
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
34 changes: 26 additions & 8 deletions examples/openclaw-memory-plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const memoryPlugin = {
});
}
} else {
clientPromise = Promise.resolve(new OpenVikingClient(cfg.baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs));
clientPromise = Promise.resolve(new OpenVikingClient(cfg.baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs, cfg.maxRetries));
}

const getClient = (): Promise<OpenVikingClient> => clientPromise;
Expand Down 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);
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 @@ -472,9 +481,17 @@ const memoryPlugin = {
}

if (cfg.autoCapture) {
let lastProcessedMsgCount = 0;

api.on("agent_end", async (event: { success?: boolean; messages?: unknown[] }) => {
const lastProcessedMsgCountMap = new Map<string, number>();

api.on("agent_end", async (event: { success?: boolean; messages?: unknown[] }, ctx?: { agentId?: string }) => {
const agentKey = ctx?.agentId || "default";
// Dynamically switch agent identity for multi-agent memory isolation
const hookAgentId = ctx?.agentId;
if (hookAgentId) {
const client = await getClient();
client.setAgentId(hookAgentId);
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 @@ -483,8 +500,9 @@ const memoryPlugin = {
}
try {
const messages = event.messages;
const lastProcessedMsgCount = lastProcessedMsgCountMap.get(agentKey) || 0;
const { texts: newTexts, newCount } = extractNewTurnTexts(messages, lastProcessedMsgCount);
lastProcessedMsgCount = messages.length;
lastProcessedMsgCountMap.set(agentKey, messages.length);

if (newTexts.length === 0) {
api.logger.info("memory-openviking: auto-capture skipped (no new user/assistant messages)");
Expand All @@ -495,7 +513,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 Expand Up @@ -616,7 +634,7 @@ const memoryPlugin = {
});
try {
await waitForHealth(baseUrl, timeoutMs, intervalMs);
const client = new OpenVikingClient(baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs);
const client = new OpenVikingClient(baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs, cfg.maxRetries);
localClientCache.set(localCacheKey, { client, process: child });
resolveLocalClient(client);
rejectLocalClient = null;
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