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
59 changes: 59 additions & 0 deletions examples/openclaw-plugin/__tests__/sender-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";

import { memoryOpenVikingConfigSchema } from "../config.js";
import { extractSenderId } from "../context-engine.js";

describe("extractSenderId", () => {
it("returns found=true for non-empty senderId string", () => {
expect(extractSenderId({ senderId: "alice" })).toEqual({
found: true,
senderId: "alice",
});
});

it("trims surrounding whitespace", () => {
expect(extractSenderId({ senderId: " bob " })).toEqual({
found: true,
senderId: "bob",
});
});

it("returns found=false for missing runtimeContext", () => {
expect(extractSenderId(undefined)).toEqual({ found: false });
});

it("returns found=false when senderId key is absent", () => {
expect(extractSenderId({})).toEqual({ found: false });
});

it("returns found=false for empty or whitespace-only string", () => {
expect(extractSenderId({ senderId: "" })).toEqual({ found: false });
expect(extractSenderId({ senderId: " " })).toEqual({ found: false });
});

it("returns found=false for non-string senderId values", () => {
expect(extractSenderId({ senderId: 42 })).toEqual({ found: false });
expect(extractSenderId({ senderId: null })).toEqual({ found: false });
expect(extractSenderId({ senderId: { id: "x" } })).toEqual({ found: false });
});
});

describe("userMode config", () => {
it("accepts single-user and multi-user", () => {
const single = memoryOpenVikingConfigSchema.parse({ userMode: "single-user" });
const multi = memoryOpenVikingConfigSchema.parse({ userMode: "multi-user" });
expect(single.userMode).toBe("single-user");
expect(multi.userMode).toBe("multi-user");
});

it("defaults to single-user when absent", () => {
const cfg = memoryOpenVikingConfigSchema.parse({});
expect(cfg.userMode).toBe("single-user");
});

it("rejects unknown userMode values", () => {
expect(() =>
memoryOpenVikingConfigSchema.parse({ userMode: "group-chat" }),
).toThrow(/userMode/);
});
});
6 changes: 6 additions & 0 deletions examples/openclaw-plugin/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,15 +645,20 @@ export class OpenVikingClient {
}>,
agentId?: string,
createdAt?: string,
roleId?: string,
): Promise<void> {
const body: {
role: string;
parts: typeof parts;
created_at?: string;
role_id?: string;
} = { role, parts };
if (createdAt) {
body.created_at = createdAt;
}
if (typeof roleId === "string" && roleId.trim()) {
body.role_id = roleId.trim();
}
await this.emitRoutingDebug(
"session message POST (with parts)",
{
Expand All @@ -662,6 +667,7 @@ export class OpenVikingClient {
role,
partCount: parts.length,
created_at: createdAt ?? null,
role_id: body.role_id ?? null,
},
agentId,
);
Expand Down
26 changes: 26 additions & 0 deletions examples/openclaw-plugin/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export type MemoryOpenVikingConfig = {
emitStandardDiagnostics?: boolean;
/** When true, log tenant routing for semantic find and session writes (messages/commit) to the plugin logger. */
logFindRequests?: boolean;
/**
* Identity model for incoming messages.
* - "single-user" (default): legacy behavior; all messages from one user.
* - "multi-user": group-chat style; each message may originate from a different user,
* and the plugin forwards `runtimeContext.senderId` as `role_id` when present.
*/
userMode?: "single-user" | "multi-user";
};

const DEFAULT_BASE_URL = "http://127.0.0.1:1933";
Expand Down Expand Up @@ -167,6 +174,7 @@ export const memoryOpenVikingConfigSchema = {
"ingestReplyAssistIgnoreSessionPatterns",
"emitStandardDiagnostics",
"logFindRequests",
"userMode",
],
"openviking config",
);
Expand Down Expand Up @@ -197,6 +205,15 @@ export const memoryOpenVikingConfigSchema = {
throw new Error(`openviking captureMode must be "semantic" or "keyword"`);
}

const userMode = cfg.userMode;
if (
typeof userMode !== "undefined" &&
userMode !== "single-user" &&
userMode !== "multi-user"
) {
throw new Error(`openviking userMode must be "single-user" or "multi-user"`);
}

return {
mode,
configPath,
Expand Down Expand Up @@ -270,6 +287,7 @@ export const memoryOpenVikingConfigSchema = {
cfg.logFindRequests === true ||
envFlag("OPENVIKING_LOG_ROUTING") ||
envFlag("OPENVIKING_DEBUG"),
userMode: userMode ?? "single-user",
};
},
uiHints: {
Expand Down Expand Up @@ -408,6 +426,14 @@ export const memoryOpenVikingConfigSchema = {
"Or set env OPENVIKING_LOG_ROUTING=1 or OPENVIKING_DEBUG=1 (no JSON edit). When on, local-mode OpenViking subprocess stderr is also logged at info.",
advanced: true,
},
userMode: {
label: "User Mode",
help:
'Identity model for incoming messages. "single-user" (default) keeps legacy behavior. ' +
'"multi-user" enables group-chat mode: when runtimeContext.senderId is present, it is ' +
"forwarded as role_id on session message writes.",
advanced: true,
},
},
};

Expand Down
34 changes: 34 additions & 0 deletions examples/openclaw-plugin/context-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,34 @@ const OPENVIKING_OV_SESSION_UUID =

const WINDOWS_BAD_SESSION_SEGMENT = /[:<>"\\/|?\u0000-\u001f]/;

export type ExtractedSender = {
found: boolean;
senderId?: string;
};

/**
* Extract a senderId from a plugin runtimeContext.
*
* Only reads `runtimeContext.senderId`. Only non-empty string values count as
* a hit. Does not throw, does not infer, does not fall back to other fields.
*/
export function extractSenderId(
runtimeContext: Record<string, unknown> | undefined,
): ExtractedSender {
if (!runtimeContext) {
return { found: false };
}
const raw = (runtimeContext as Record<string, unknown>).senderId;
if (typeof raw !== "string") {
return { found: false };
}
const trimmed = raw.trim();
if (!trimmed) {
return { found: false };
}
return { found: true, senderId: trimmed };
}

/**
* Map OpenClaw session identity to an OpenViking session_id that is safe as a single
* AGFS path segment on Windows (no `:` etc.). Prefer UUID sessionId when present;
Expand Down Expand Up @@ -788,6 +816,7 @@ export function createMemoryOpenVikingContextEngine(params: {
const { messages } = assembleParams;
const tokenBudget = validTokenBudget(assembleParams.tokenBudget) ?? 128_000;
const sessionKey = extractAssembleSessionKey(assembleParams);
const sender = extractSenderId(assembleParams.runtimeContext);

const originalTokens = roughEstimate(messages);

Expand All @@ -803,6 +832,7 @@ export function createMemoryOpenVikingContextEngine(params: {
inputTokenEstimate: originalTokens,
tokenBudget,
sessionKey: sessionKey ?? null,
senderId: sender.found ? sender.senderId ?? null : null,
messages: messageDigest(messages),
});

Expand Down Expand Up @@ -901,6 +931,9 @@ export function createMemoryOpenVikingContextEngine(params: {
const sessionKey =
(typeof afterTurnParams.sessionKey === "string" && afterTurnParams.sessionKey.trim()) ||
extractSessionKey(afterTurnParams.runtimeContext);
const sender = extractSenderId(afterTurnParams.runtimeContext);
const roleId =
cfg.userMode === "multi-user" && sender.found ? sender.senderId : undefined;
const OVSessionId = openClawSessionToOvStorageId(
afterTurnParams.sessionId,
sessionKey,
Expand Down Expand Up @@ -1007,6 +1040,7 @@ export function createMemoryOpenVikingContextEngine(params: {
ovParts,
agentId,
createdAt,
roleId,
);
}
}
Expand Down