diff --git a/examples/openclaw-plugin/README.md b/examples/openclaw-plugin/README.md index 104ffbe4..db4cad28 100644 --- a/examples/openclaw-plugin/README.md +++ b/examples/openclaw-plugin/README.md @@ -30,6 +30,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) @@ -406,6 +407,31 @@ 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). | + +> **Note on `"main"` agent ID:** OpenClaw's default primary agent ID is `"main"`. With per-agent isolation enabled (the default), memories are stored under the `"main"` namespace — not the legacy `"default"` namespace. If you previously used a fixed `agentId: "default"` config, those memories remain under `"default"` and will not be visible to the `"main"` agent. To continue accessing them, explicitly set `agentId: "default"` in your config. + +> **Tool isolation limitation:** Auto-recall and auto-capture (the background memory pipeline) are fully per-agent isolated — the plugin receives agent context from OpenClaw via hook events and routes each agent's memories correctly. However, explicit tool calls (`memory_store`, `memory_recall`, `memory_forget`) do not currently inherit the calling agent's identity. They use the configured `agentId` value, or `"default"` if none is set. This is a platform-level constraint: the OpenClaw tool execution API does not pass agent context into tool `execute` callbacks. Full tool-level isolation would require a change to the OpenClaw platform. + +### 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 diff --git a/examples/openclaw-plugin/__tests__/multi-agent-isolation.test.ts b/examples/openclaw-plugin/__tests__/multi-agent-isolation.test.ts new file mode 100644 index 00000000..81a75df6 --- /dev/null +++ b/examples/openclaw-plugin/__tests__/multi-agent-isolation.test.ts @@ -0,0 +1,552 @@ +/** + * Integration tests for PR #597 – Multi-agent memory isolation fix. + * + * These tests verify that: + * 1. Two agents writing memories simultaneously do NOT contaminate each other. + * 2. Per-agent cache keys are isolated (composite scope:agentId key model). + * 3. lastProcessedMsgCount (prePromptMessageCount) is tracked per-agent, not shared. + * 4. "main" agent correctly maps through resolveAgentId (backward compat – "main" is + * treated as an explicit, non-empty agentId, not collapsed to "default"). + * 5. A single-agent setup still works (backward compat with no agentId in config). + * + * The suite is self-contained: it spins up a tiny in-process mock HTTP server for each + * test group so no real OpenViking instance is needed. + * + * Run with: + * npx tsx --test __tests__/multi-agent-isolation.test.ts + * or (after installing tsx as a devDependency): + * node --import tsx/esm --test __tests__/multi-agent-isolation.test.ts + */ + +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { createHash } from "node:crypto"; +import { test, describe, before, after } from "node:test"; +import assert from "node:assert/strict"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function md5Short(input: string): string { + return createHash("md5").update(input).digest("hex").slice(0, 12); +} + +/** Minimal request/response log entry captured by the mock server. */ +type CapturedRequest = { + method: string; + path: string; + body: Record; + agentHeader: string | null; +}; + +/** Tiny mock OpenViking HTTP server. Returns configurable fixtures. */ +function createMockServer(fixtures: { + /** Queued session IDs returned by POST /api/v1/sessions */ + sessionIds?: string[]; + /** Fixed user id returned by GET /api/v1/system/status */ + userId?: string; +}) { + const captured: CapturedRequest[] = []; + let sessionIdQueue = fixtures.sessionIds ? [...fixtures.sessionIds] : ["sess-001"]; + const userId = fixtures.userId ?? "testuser"; + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const rawBody = Buffer.concat(chunks).toString("utf-8"); + let body: Record = {}; + try { + body = rawBody ? (JSON.parse(rawBody) as Record) : {}; + } catch { + // ignore parse errors + } + + const agentHeader = req.headers["x-openviking-agent"] as string | undefined | null ?? null; + const url = req.url ?? "/"; + const method = req.method ?? "GET"; + captured.push({ method, path: url, body, agentHeader }); + + res.setHeader("Content-Type", "application/json"); + + if (url === "/health") { + res.writeHead(200); + res.end(JSON.stringify({ status: "ok" })); + return; + } + + if (url === "/api/v1/system/status" && method === "GET") { + res.writeHead(200); + res.end(JSON.stringify({ status: "ok", result: { user: userId } })); + return; + } + + if (url === "/api/v1/sessions" && method === "POST") { + const sid = sessionIdQueue.shift() ?? `sess-${Date.now()}`; + res.writeHead(200); + res.end(JSON.stringify({ status: "ok", result: { session_id: sid } })); + return; + } + + if (url.includes("/api/v1/sessions/") && method === "POST" && url.endsWith("/messages")) { + res.writeHead(200); + res.end(JSON.stringify({ status: "ok", result: {} })); + return; + } + + if (url.includes("/api/v1/sessions/") && method === "POST" && url.endsWith("/extract")) { + res.writeHead(200); + res.end( + JSON.stringify({ + status: "ok", + result: [{ uri: "viking://agent/memories/extracted-1", abstract: "test memory" }], + }), + ); + return; + } + + if (url.includes("/api/v1/sessions/") && method === "GET") { + res.writeHead(200); + res.end(JSON.stringify({ status: "ok", result: { message_count: 1 } })); + return; + } + + if (url.includes("/api/v1/sessions/") && method === "DELETE") { + res.writeHead(200); + res.end(JSON.stringify({ status: "ok", result: {} })); + return; + } + + if (url.startsWith("/api/v1/search/find") && method === "POST") { + res.writeHead(200); + res.end(JSON.stringify({ status: "ok", result: { memories: [], total: 0 } })); + return; + } + + if (url.startsWith("/api/v1/fs/ls") && method === "GET") { + res.writeHead(200); + res.end(JSON.stringify({ status: "ok", result: [] })); + return; + } + + // Fallback + res.writeHead(404); + res.end(JSON.stringify({ status: "error", error: { message: `Unknown route: ${url}` } })); + }); + + return { + server, + captured, + listen(): Promise { + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address() as { port: number }; + resolve(addr.port); + }); + }); + }, + close(): Promise { + return new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + }, + }; +} + +// --------------------------------------------------------------------------- +// Dynamically import the plugin modules. +// We use dynamic import() so these tests can run via tsx/esm without a build step. +// --------------------------------------------------------------------------- + +// NOTE: TypeScript types are not re-exported at runtime; we use `any` for the +// imported class so the test file itself doesn't require transpilation of +// generic constraints. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyClient = any; + +async function loadClientModule(): Promise<{ + OpenVikingClient: new ( + baseUrl: string, + apiKey: string, + timeoutMs: number, + ) => AnyClient; +}> { + // The plugin uses .js extensions in imports (ESM Node16 resolution), + // so we import the .ts source directly via tsx. + return import("../client.js") as Promise<{ + OpenVikingClient: new ( + baseUrl: string, + apiKey: string, + timeoutMs: number, + ) => AnyClient; + }>; +} + +async function loadConfigModule(): Promise<{ + memoryOpenVikingConfigSchema: { + parse: (v: unknown) => Record; + }; +}> { + return import("../config.js") as Promise<{ + memoryOpenVikingConfigSchema: { parse: (v: unknown) => Record }; + }>; +} + +// --------------------------------------------------------------------------- +// Test 1 – Two agents writing simultaneously do NOT contaminate each other +// --------------------------------------------------------------------------- + +describe("PR #597 – Multi-agent memory isolation", () => { + // Shared mock server for agent isolation tests + let mockPort: number; + let mock: ReturnType; + let OpenVikingClient: Awaited>["OpenVikingClient"]; + + before(async () => { + mock = createMockServer({ + sessionIds: ["sess-agentA-001", "sess-agentA-002", "sess-agentB-001", "sess-agentB-002"], + userId: "alice", + }); + mockPort = await mock.listen(); + + const mod = await loadClientModule(); + OpenVikingClient = mod.OpenVikingClient; + }); + + after(async () => { + await mock.close(); + }); + + test("agents use different X-OpenViking-Agent headers → no cross-contamination", async () => { + const baseUrl = `http://127.0.0.1:${mockPort}`; + + // Simulate two agents sharing the same client but different agentIds (stateless per-request) + const client = new OpenVikingClient(baseUrl, "", 5000); + + // Both agents create sessions and write memories "simultaneously" + await Promise.all([ + (async () => { + const sid = await client.createSession("agent-alpha"); + await client.addSessionMessage(sid, "user", "Agent Alpha prefers dark mode", "agent-alpha"); + await client.extractSessionMemories(sid, "agent-alpha"); + await client.deleteSession(sid, "agent-alpha"); + })(), + (async () => { + const sid = await client.createSession("agent-beta"); + await client.addSessionMessage(sid, "user", "Agent Beta prefers light mode", "agent-beta"); + await client.extractSessionMemories(sid, "agent-beta"); + await client.deleteSession(sid, "agent-beta"); + })(), + ]); + + // Verify that all extract requests carried their respective agent headers + const extractRequests = mock.captured.filter( + (r) => r.path.endsWith("/extract") && r.method === "POST", + ); + assert.ok(extractRequests.length >= 2, "Expected at least 2 extract calls"); + + const alphaExtracts = extractRequests.filter((r) => r.agentHeader === "agent-alpha"); + const betaExtracts = extractRequests.filter((r) => r.agentHeader === "agent-beta"); + + assert.ok(alphaExtracts.length >= 1, "agent-alpha must have sent at least one extract request"); + assert.ok(betaExtracts.length >= 1, "agent-beta must have sent at least one extract request"); + + // No extract request should have bled the wrong agent header + for (const req of alphaExtracts) { + assert.equal(req.agentHeader, "agent-alpha", "alpha extract must carry alpha header only"); + } + for (const req of betaExtracts) { + assert.equal(req.agentHeader, "agent-beta", "beta extract must carry beta header only"); + } + }); + + // --------------------------------------------------------------------------- + // Test 2 – Per-agent cache keys are isolated (scope:agentId composite) + // --------------------------------------------------------------------------- + + test("per-agentId composite cache keys are isolated – each agentId triggers its own ls call", async () => { + // Start a fresh mock for scope-resolution so we can observe ls calls + const lsMock = createMockServer({ userId: "bob" }); + const lsPort = await lsMock.listen(); + const lsBaseUrl = `http://127.0.0.1:${lsPort}`; + + try { + const { OpenVikingClient: Client } = await loadClientModule(); + const client = new Client(lsBaseUrl, "", 5000); + + // Prime cache for agent-one by calling find (which calls resolveScopeSpace internally) + await client.find("query", { targetUri: "viking://agent/memories", limit: 5, agentId: "agent-one" }); + + // Capture ls calls for agent-one + const lsCallsAfterAgentOne = lsMock.captured.filter((r) => r.path.startsWith("/api/v1/fs/ls")).length; + + // Call find for agent-two — different composite cache key "agent:agent-two" vs "agent:agent-one" + // so a fresh system/status + ls cycle must occur. + await client.find("query", { targetUri: "viking://agent/memories", limit: 5, agentId: "agent-two" }); + + const lsCallsAfterAgentTwo = lsMock.captured.filter((r) => r.path.startsWith("/api/v1/fs/ls")).length; + + // There should be more ls calls after the second agent (different cache key, not reused) + assert.ok( + lsCallsAfterAgentTwo > lsCallsAfterAgentOne, + `Expected additional ls calls for different agentId; before=${lsCallsAfterAgentOne}, after=${lsCallsAfterAgentTwo}`, + ); + } finally { + await lsMock.close(); + } + }); + + // --------------------------------------------------------------------------- + // Test 3 – lastProcessedMsgCount is tracked per-agent, not shared + // NOTE: Design contract test. This test verifies the isolation semantics of + // extractNewTurnTexts (a pure text utility) with independent per-agent offsets. + // It does not test whether context-engine.ts actually tracks per-agent offsets + // in practice — that would require invoking the context engine's afterTurn path. + // --------------------------------------------------------------------------- + + test("context engine afterTurn uses per-agent prePromptMessageCount, not a shared counter", async () => { + /** + * This tests the isolation of prePromptMessageCount (lastProcessedMsgCount equivalent). + * In the context engine, afterTurn receives `prePromptMessageCount` per call. + * Each agent's session must start extraction from its own offset, not a global one. + * + * We simulate two sequential afterTurn calls with different sessionIds and verify + * that extractNewTurnTexts is applied correctly based on the provided startIndex. + */ + const { extractNewTurnTexts } = await import("../text-utils.js"); + + const messagesAgentA = [ + { role: "user", content: "Hello from session A message 1" }, + { role: "assistant", content: "Response A1" }, + { role: "user", content: "Hello from session A message 2" }, + { role: "assistant", content: "Response A2" }, + ]; + + const messagesAgentB = [ + { role: "user", content: "Hello from session B message 1" }, + { role: "assistant", content: "Response B1" }, + ]; + + // Agent A has seen 2 messages already; new messages start at index 2 + const agentAStart = 2; + const { texts: textsA, newCount: newCountA } = extractNewTurnTexts(messagesAgentA, agentAStart); + + // Agent B starts fresh (0 messages processed) + const agentBStart = 0; + const { texts: textsB, newCount: newCountB } = extractNewTurnTexts(messagesAgentB, agentBStart); + + // Agent A should only see its last 2 messages (indices 2 and 3) + assert.equal(newCountA, 2, "Agent A afterTurn should see exactly 2 new messages"); + assert.ok( + textsA.some((t) => t.includes("session A message 2")), + "Agent A new texts should include message 2", + ); + assert.ok( + !textsA.some((t) => t.includes("session A message 1")), + "Agent A new texts must NOT include already-processed message 1", + ); + + // Agent B should see all 2 messages (both are new) + assert.equal(newCountB, 2, "Agent B afterTurn should see 2 messages from its own session"); + assert.ok( + textsB.some((t) => t.includes("session B message 1")), + "Agent B new texts should include its own messages", + ); + + // Critical: Agent B's offset must not bleed Agent A's offset + assert.notEqual( + agentAStart, + agentBStart, + "Agents must have independent prePromptMessageCount values", + ); + }); + + // --------------------------------------------------------------------------- + // Test 4 – "main" agent maps correctly for backward compatibility + // --------------------------------------------------------------------------- + + test('resolveAgentId treats "main" as an explicit named agentId (not silently changed to "default")', async () => { + const { memoryOpenVikingConfigSchema } = await loadConfigModule(); + + // "main" is a common legacy value. It should be preserved as-is in the config + // because it is a non-empty string. + const cfg = memoryOpenVikingConfigSchema.parse({ mode: "remote", baseUrl: "http://localhost:1933", agentId: "main" }); + assert.equal( + cfg.agentId, + "main", + '"main" agentId must be preserved as-is for backward compat', + ); + + // Confirm agentId is undefined when not set (per-agent isolation default) + const cfgNoAgent = memoryOpenVikingConfigSchema.parse({ mode: "remote", baseUrl: "http://localhost:1933" }); + assert.equal( + cfgNoAgent.agentId, + undefined, + "omitted agentId must be undefined (per-agent isolation, host provides the ID)", + ); + + const cfgEmptyAgent = memoryOpenVikingConfigSchema.parse({ mode: "remote", baseUrl: "http://localhost:1933", agentId: "" }); + assert.equal( + cfgEmptyAgent.agentId, + undefined, + 'empty-string agentId must be undefined', + ); + + const cfgWhitespaceAgent = memoryOpenVikingConfigSchema.parse({ mode: "remote", baseUrl: "http://localhost:1933", agentId: " " }); + assert.equal( + cfgWhitespaceAgent.agentId, + undefined, + 'whitespace-only agentId must be undefined', + ); + }); + + // --------------------------------------------------------------------------- + // Test 5 – Single-agent setup still works (backward compatibility) + // --------------------------------------------------------------------------- + + test("single-agent config parses and operates correctly (backward compat)", async () => { + const { memoryOpenVikingConfigSchema } = await loadConfigModule(); + + // Minimal valid config – no agentId, no apiKey, remote mode + const cfg = memoryOpenVikingConfigSchema.parse({ + mode: "remote", + baseUrl: "http://127.0.0.1:1933", + }); + + assert.equal(cfg.mode, "remote"); + assert.equal(cfg.agentId, undefined); + assert.equal(typeof cfg.baseUrl, "string"); + assert.ok(cfg.baseUrl.startsWith("http://")); + assert.equal(cfg.autoCapture, true, "autoCapture defaults to true"); + assert.equal(cfg.autoRecall, true, "autoRecall defaults to true"); + assert.ok(cfg.recallLimit >= 1, "recallLimit must be >= 1"); + assert.ok(cfg.recallScoreThreshold >= 0 && cfg.recallScoreThreshold <= 1); + + // Verify single-agent find works end-to-end with a mock server + const singleMock = createMockServer({ userId: "solo-user" }); + const singlePort = await singleMock.listen(); + try { + const { OpenVikingClient: Client } = await loadClientModule(); + const client = new Client(`http://127.0.0.1:${singlePort}`, "", 5000); + + const result = await client.find("test query", { + targetUri: "viking://user/memories", + limit: 5, + scoreThreshold: 0, + agentId: "default", + }); + + assert.ok(Array.isArray(result.memories), "find must return a memories array"); + + // Verify the agent header sent + const findReq = singleMock.captured.find((r) => r.path.includes("/api/v1/search/find")); + assert.ok(findReq, "find request must have been received by mock server"); + assert.equal( + findReq.agentHeader, + "default", + 'single-agent request must carry "default" agent header', + ); + } finally { + await singleMock.close(); + } + }); + + // --------------------------------------------------------------------------- + // Test 6 – Agent space derivation is deterministic and agent-specific + // NOTE: Design contract test. This test reimplements md5Short locally rather than + // importing it from client.ts. It verifies the composite key derivation contract + // (md5(userId:agentId) produces distinct spaces per agent), but will not catch + // regressions if the production md5Short implementation changes (e.g. hash length). + // --------------------------------------------------------------------------- + + test("agent space key is derived from md5(userId:agentId) – two agents produce distinct spaces", () => { + const userId = "alice"; + const agentIdA = "agent-alpha"; + const agentIdB = "agent-beta"; + + const spaceA = md5Short(`${userId}:${agentIdA}`); + const spaceB = md5Short(`${userId}:${agentIdB}`); + + assert.notEqual(spaceA, spaceB, "Different agentIds must produce distinct space hashes"); + assert.equal(spaceA.length, 12, "Space hash must be 12 hex chars"); + assert.equal(spaceB.length, 12, "Space hash must be 12 hex chars"); + + // Idempotent: same inputs always produce the same space + assert.equal(md5Short(`${userId}:${agentIdA}`), spaceA, "Space derivation must be deterministic"); + }); + + // --------------------------------------------------------------------------- + // Test 7 – setAgentId is idempotent (no cache clear when agentId unchanged) + // --------------------------------------------------------------------------- + + test("same agentId on repeated find() calls reuses composite cache key (no extra ls calls)", async () => { + const stableMock = createMockServer({ userId: "stable-user" }); + const stablePort = await stableMock.listen(); + const stableBaseUrl = `http://127.0.0.1:${stablePort}`; + + try { + const { OpenVikingClient: Client } = await loadClientModule(); + const client = new Client(stableBaseUrl, "", 5000); + + // Prime the cache for stable-agent + await client.find("query", { targetUri: "viking://agent/memories", limit: 3, agentId: "stable-agent" }); + const lsCountAfterFirst = stableMock.captured.filter((r) => r.path.startsWith("/api/v1/fs/ls")).length; + + // Same agentId – composite cache key "agent:stable-agent" should be reused + await client.find("query2", { targetUri: "viking://agent/memories", limit: 3, agentId: "stable-agent" }); + const lsCountAfterSecond = stableMock.captured.filter((r) => r.path.startsWith("/api/v1/fs/ls")).length; + + assert.equal( + lsCountAfterFirst, + lsCountAfterSecond, + "repeated find() with same agentId must reuse resolved space cache (no extra ls calls)", + ); + } finally { + await stableMock.close(); + } + }); + + // --------------------------------------------------------------------------- + // Test 8 – sessionAgentIds map correctly routes sessions to agent identities + // NOTE: Design contract test. This test reimplements rememberSessionAgentId and + // resolveAgentId locally rather than importing from index.ts. It verifies the + // session-to-agent routing contract, but changes to the production implementations + // in index.ts would not cause this test to fail. + // --------------------------------------------------------------------------- + + test("sessionAgentIds map isolates session-to-agent routing (simulates index.ts hook logic)", () => { + /** + * index.ts maintains a Map via rememberSessionAgentId(). + * resolveAgentId(sessionId) looks up the map and falls back to cfg.agentId. + * This test directly verifies that logic. + */ + const cfgAgentId = "default"; + const sessionAgentIds = new Map(); + + function rememberSessionAgentId(ctx: { agentId?: string; sessionId?: string; sessionKey?: string }) { + if (!ctx?.agentId) return; + if (ctx.sessionId) sessionAgentIds.set(ctx.sessionId, ctx.agentId); + if (ctx.sessionKey) sessionAgentIds.set(ctx.sessionKey, ctx.agentId); + } + + function resolveAgentId(sessionId: string): string { + return sessionAgentIds.get(sessionId) ?? cfgAgentId; + } + + // Two agents register their sessions + rememberSessionAgentId({ agentId: "agent-A", sessionId: "session-123" }); + rememberSessionAgentId({ agentId: "agent-B", sessionId: "session-456" }); + + assert.equal(resolveAgentId("session-123"), "agent-A", "session-123 must resolve to agent-A"); + assert.equal(resolveAgentId("session-456"), "agent-B", "session-456 must resolve to agent-B"); + assert.equal( + resolveAgentId("unknown-session"), + cfgAgentId, + "unknown session must fall back to cfg.agentId", + ); + + // Agent-A session must NOT resolve to agent-B + assert.notEqual(resolveAgentId("session-123"), "agent-B", "agent-A session must not resolve to agent-B"); + }); +}); diff --git a/examples/openclaw-plugin/client.ts b/examples/openclaw-plugin/client.ts index edc11f48..fc3e1070 100644 --- a/examples/openclaw-plugin/client.ts +++ b/examples/openclaw-plugin/client.ts @@ -58,37 +58,16 @@ export function isMemoryUri(uri: string): boolean { } export class OpenVikingClient { - private resolvedSpaceByScope: Partial> = {}; - private runtimeIdentity: RuntimeIdentity | null = null; + private readonly resolvedSpaceCache = new Map(); + 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(path: string, init: RequestInit = {}): Promise { + private async request(path: string, init: RequestInit = {}, agentId?: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); try { @@ -96,8 +75,8 @@ export class OpenVikingClient { 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"); @@ -131,36 +110,41 @@ export class OpenVikingClient { await this.request<{ status: string }>("/health"); } - private async ls(uri: string): Promise>> { + private async ls(uri: string, agentId: string): Promise>> { return this.request>>( `/api/v1/fs/ls?uri=${encodeURIComponent(uri)}&output=original`, + {}, + agentId ); } - private async getRuntimeIdentity(): Promise { - if (this.runtimeIdentity) { - return this.runtimeIdentity; + private async getUserId(): Promise { + 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 { + const userId = await this.getUserId(); + return { userId, agentId: agentId || "default" }; } - private async resolveScopeSpace(scope: ScopeName): Promise { - const cached = this.resolvedSpaceByScope[scope]; + private async resolveScopeSpace(scope: ScopeName, agentId: string): Promise { + 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; @@ -168,7 +152,7 @@ export class OpenVikingClient { 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() : "")) @@ -176,15 +160,15 @@ export class OpenVikingClient { 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]!; } } @@ -192,11 +176,11 @@ export class OpenVikingClient { // 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 { + private async normalizeTargetUri(targetUri: string, agentId: string): Promise { const trimmed = targetUri.trim().replace(/\/+$/, ""); const match = trimmed.match(/^viking:\/\/(user|agent)(?:\/(.*))?$/); if (!match) { @@ -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("/")}`; } @@ -227,9 +211,10 @@ export class OpenVikingClient { targetUri: string; limit: number; scoreThreshold?: number; + agentId: string; }, ): Promise { - const normalizedTargetUri = await this.normalizeTargetUri(options.targetUri); + const normalizedTargetUri = await this.normalizeTargetUri(options.targetUri, options.agentId); const body = { query, target_uri: normalizedTargetUri, @@ -239,55 +224,60 @@ export class OpenVikingClient { return this.request("/api/v1/search/find", { method: "POST", body: JSON.stringify(body), - }); + }, options.agentId); } - async read(uri: string): Promise { + async read(uri: string, agentId: string): Promise { return this.request( `/api/v1/content/read?uri=${encodeURIComponent(uri)}`, + {}, + agentId, ); } - async createSession(): Promise { + async createSession(agentId: string): Promise { 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 { + async addSessionMessage(sessionId: string, role: string, content: string, agentId: string): Promise { 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>> { + async extractSessionMemories(sessionId: string, agentId: string): Promise>> { return this.request>>( `/api/v1/sessions/${encodeURIComponent(sessionId)}/extract`, { method: "POST", body: JSON.stringify({}) }, + agentId, ); } - async deleteSession(sessionId: string): Promise { - await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }); + async deleteSession(sessionId: string, agentId: string): Promise { + await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" }, agentId); } - async deleteUri(uri: string): Promise { + async deleteUri(uri: string, agentId: string): Promise { await this.request(`/api/v1/fs?uri=${encodeURIComponent(uri)}&recursive=false`, { method: "DELETE", - }); + }, agentId); } } diff --git a/examples/openclaw-plugin/config.ts b/examples/openclaw-plugin/config.ts index c7a46561..25d05c0a 100644 --- a/examples/openclaw-plugin/config.ts +++ b/examples/openclaw-plugin/config.ts @@ -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 { @@ -87,7 +84,7 @@ function resolveDefaultBaseUrl(): string { } export const memoryOpenVikingConfigSchema = { - parse(value: unknown): Required { + parse(value: unknown): Required> & Pick { if (!value || typeof value !== "object" || Array.isArray(value)) { value = {}; } @@ -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. Set a fixed value (e.g. \"default\") to share one namespace across all agents. Note: explicit tool calls (memory_store, memory_recall, memory_forget) use this value (or \"default\") and do not automatically inherit the calling agent's identity.", }, apiKey: { label: "OpenViking API Key", diff --git a/examples/openclaw-plugin/context-engine.ts b/examples/openclaw-plugin/context-engine.ts index cae34e69..33a33c86 100644 --- a/examples/openclaw-plugin/context-engine.ts +++ b/examples/openclaw-plugin/context-engine.ts @@ -145,16 +145,6 @@ export function createMemoryOpenVikingContextEngine(params: { resolveAgentId, } = params; - const switchClientAgent = async (sessionId: string, phase: "assemble" | "afterTurn") => { - const client = await getClient(); - const resolvedAgentId = resolveAgentId(sessionId); - const before = client.getAgentId(); - if (resolvedAgentId && resolvedAgentId !== before) { - client.setAgentId(resolvedAgentId); - logger.info(`openviking: switched to agentId=${resolvedAgentId} for ${phase}`); - } - return client; - }; return { info: { @@ -186,7 +176,7 @@ export function createMemoryOpenVikingContextEngine(params: { } try { - await switchClientAgent(afterTurnParams.sessionId, "afterTurn"); + const agentId = resolveAgentId(afterTurnParams.sessionId); const messages = afterTurnParams.messages ?? []; if (messages.length === 0) { @@ -222,11 +212,11 @@ export function createMemoryOpenVikingContextEngine(params: { } const client = await getClient(); - const sessionId = await client.createSession(); + const sessionId = await client.createSession(agentId); try { - await client.addSessionMessage(sessionId, "user", decision.normalizedText); - await client.getSession(sessionId).catch(() => ({})); - const extracted = await client.extractSessionMemories(sessionId); + await client.addSessionMessage(sessionId, "user", decision.normalizedText, agentId); + await client.getSession(sessionId, agentId).catch(() => ({})); + const extracted = await client.extractSessionMemories(sessionId, agentId); logger.info( `openviking: auto-captured ${newCount} new messages, extracted ${extracted.length} memories`, @@ -247,7 +237,7 @@ export function createMemoryOpenVikingContextEngine(params: { ); } } finally { - await client.deleteSession(sessionId).catch(() => {}); + await client.deleteSession(sessionId, agentId).catch(() => {}); } } catch (err) { warnOrInfo(logger, `openviking: auto-capture failed: ${String(err)}`); diff --git a/examples/openclaw-plugin/index.ts b/examples/openclaw-plugin/index.ts index 4e11dba9..95024e58 100644 --- a/examples/openclaw-plugin/index.ts +++ b/examples/openclaw-plugin/index.ts @@ -123,7 +123,7 @@ const contextEnginePlugin = { } } } else { - clientPromise = Promise.resolve(new OpenVikingClient(cfg.baseUrl, cfg.apiKey, cfg.agentId, cfg.timeoutMs)); + clientPromise = Promise.resolve(new OpenVikingClient(cfg.baseUrl, cfg.apiKey, cfg.timeoutMs)); } const getClient = (): Promise => clientPromise; @@ -162,6 +162,7 @@ const contextEnginePlugin = { : undefined; const requestLimit = Math.max(limit * 4, 20); + const agentId = getToolAgentId(); let result; if (targetUri) { // 如果指定了目标 URI,只检索该位置 @@ -169,6 +170,7 @@ const contextEnginePlugin = { targetUri, limit: requestLimit, scoreThreshold: 0, + agentId, }); } else { // 默认同时检索 user 和 agent 两个位置的记忆 @@ -177,11 +179,13 @@ const contextEnginePlugin = { targetUri: "viking://user/memories", limit: requestLimit, scoreThreshold: 0, + agentId, }), (await getClient()).find(query, { targetUri: "viking://agent/memories", limit: requestLimit, scoreThreshold: 0, + agentId, }), ]); const userResult = userSettled.status === "fulfilled" ? userSettled.value : { memories: [] }; @@ -240,6 +244,7 @@ const contextEnginePlugin = { sessionId: Type.Optional(Type.String({ description: "Existing OpenViking session ID" })), }), async execute(_toolCallId: string, params: Record) { + const agentId = getToolAgentId(); const { text } = params as { text: string }; const role = typeof (params as { role?: string }).role === "string" @@ -256,11 +261,11 @@ const contextEnginePlugin = { try { const c = await getClient(); if (!sessionId) { - sessionId = await c.createSession(); + sessionId = await c.createSession(agentId); createdTempSession = true; } - await c.addSessionMessage(sessionId, role, text); - const extracted = await c.extractSessionMemories(sessionId); + await c.addSessionMessage(sessionId, role, text, agentId); + const extracted = await c.extractSessionMemories(sessionId, agentId); if (extracted.length === 0) { api.logger.warn( `openviking: memory_store completed but extract returned 0 memories (sessionId=${sessionId}). ` + @@ -284,7 +289,7 @@ const contextEnginePlugin = { } finally { if (createdTempSession && sessionId) { const c = await getClient().catch(() => null); - if (c) await c.deleteSession(sessionId!).catch(() => {}); + if (c) await c.deleteSession(sessionId!, agentId).catch(() => {}); } } }, @@ -310,6 +315,7 @@ const contextEnginePlugin = { ), }), async execute(_toolCallId: string, params: Record) { + const agentId = getToolAgentId(); const uri = (params as { uri?: string }).uri; if (uri) { if (!isMemoryUri(uri)) { @@ -318,7 +324,7 @@ const contextEnginePlugin = { details: { action: "rejected", uri }, }; } - await (await getClient()).deleteUri(uri); + await (await getClient()).deleteUri(uri, agentId); return { content: [{ type: "text", text: `Forgotten: ${uri}` }], details: { action: "deleted", uri }, @@ -351,6 +357,7 @@ const contextEnginePlugin = { targetUri, limit: requestLimit, scoreThreshold: 0, + agentId, }); const candidates = postProcessMemories(result.memories ?? [], { limit: requestLimit, @@ -370,7 +377,7 @@ const contextEnginePlugin = { } const top = candidates[0]; if (candidates.length === 1 && clampScore(top.score) >= 0.85) { - await (await getClient()).deleteUri(top.uri); + await (await getClient()).deleteUri(top.uri, agentId); return { content: [{ type: "text", text: `Forgotten: ${top.uri}` }], details: { action: "deleted", uri: top.uri, score: top.score ?? 0 }, @@ -411,7 +418,9 @@ const contextEnginePlugin = { } }; const resolveAgentId = (sessionId: string): string => - sessionAgentIds.get(sessionId) ?? cfg.agentId; + sessionAgentIds.get(sessionId) ?? cfg.agentId ?? "default"; + + const getToolAgentId = (): string => cfg.agentId ?? "default"; api.on("session_start", async (_event: unknown, ctx?: HookAgentContext) => { rememberSessionAgentId(ctx ?? {}); @@ -435,11 +444,6 @@ const contextEnginePlugin = { api.logger.warn?.(`openviking: failed to get client: ${String(err)}`); return; } - if (resolvedAgentId && client.getAgentId() !== resolvedAgentId) { - client.setAgentId(resolvedAgentId); - api.logger.info(`openviking: switched to agentId=${resolvedAgentId} for before_prompt_build`); - } - const eventObj = (event ?? {}) as { messages?: unknown[]; prompt?: string }; const queryText = extractLatestUserText(eventObj.messages) || @@ -466,11 +470,13 @@ const contextEnginePlugin = { targetUri: "viking://user/memories", limit: candidateLimit, scoreThreshold: 0, + agentId: resolvedAgentId, }), client.find(queryText, { targetUri: "viking://agent/memories", limit: candidateLimit, scoreThreshold: 0, + agentId: resolvedAgentId, }), ]); @@ -499,7 +505,7 @@ const contextEnginePlugin = { memories.map(async (item: FindResultItem) => { if (item.level === 2) { try { - const content = await client.read(item.uri); + const content = await client.read(item.uri, resolvedAgentId); if (content && typeof content === "string" && content.trim()) { return `- [${item.category ?? "memory"}] ${content.trim()}`; } @@ -676,7 +682,7 @@ const contextEnginePlugin = { }); 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.timeoutMs); localClientCache.set(localCacheKey, { client, process: child }); resolveLocalClient!(client); rejectLocalClient = null; diff --git a/examples/openclaw-plugin/openclaw.plugin.json b/examples/openclaw-plugin/openclaw.plugin.json index ce8ee78b..12592f7a 100644 --- a/examples/openclaw-plugin/openclaw.plugin.json +++ b/examples/openclaw-plugin/openclaw.plugin.json @@ -24,8 +24,8 @@ }, "agentId": { "label": "Agent ID", - "placeholder": "random unique ID", - "help": "Identifies this agent to OpenViking. A random unique ID is generated 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", diff --git a/examples/openclaw-plugin/package-lock.json b/examples/openclaw-plugin/package-lock.json index 624797df..89472928 100644 --- a/examples/openclaw-plugin/package-lock.json +++ b/examples/openclaw-plugin/package-lock.json @@ -11,7 +11,450 @@ "@sinclair/typebox": "0.34.48" }, "devDependencies": { - "@types/node": "^25.3.5" + "@types/node": "^25.3.5", + "tsx": "^4.19.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@sinclair/typebox": { @@ -30,6 +473,106 @@ "undici-types": "~7.18.0" } }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.18.2.tgz", diff --git a/examples/openclaw-plugin/package.json b/examples/openclaw-plugin/package.json index 60bb946a..83d14fa2 100644 --- a/examples/openclaw-plugin/package.json +++ b/examples/openclaw-plugin/package.json @@ -11,7 +11,12 @@ "./index.ts" ] }, + "scripts": { + "test": "node --import tsx/esm --test '__tests__/**/*.test.ts'", + "test:typecheck": "tsc --noEmit" + }, "devDependencies": { - "@types/node": "^25.3.5" + "@types/node": "^25.3.5", + "tsx": "^4.19.2" } } diff --git a/examples/openclaw-plugin/tsconfig.json b/examples/openclaw-plugin/tsconfig.json index 158c2009..b8267a61 100644 --- a/examples/openclaw-plugin/tsconfig.json +++ b/examples/openclaw-plugin/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "types": ["node"] }, - "include": ["*.ts"] + "include": ["*.ts", "__tests__/**/*.ts"] }