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
49 changes: 48 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,52 @@ Per-call override at the SDK / REST layer: every mutating endpoint (`/session/st

When `AGENT_ID` is unset, memory remains unscoped (legacy behavior, no tags, no filters).

### Multi-user team memory (`USER_ID` + `AGENTMEMORY_USER_ID` + `TEAM_MODE`)

In setups where multiple people share one agentmemory server (small team), `USER_ID` sets the server-level default, while `AGENTMEMORY_USER_ID` overrides it per-integration. `TEAM_MODE` controls whether users can see each other's shared items.

Server `.env`:

```env
TEAM_ID=acme
USER_ID=alice # server-level default
TEAM_MODE=private
```

Each integration sets `AGENTMEMORY_USER_ID` in its own `.env` to override the server default:

```env
# Hermes plugin
AGENTMEMORY_USER_ID=alice

# OpenClaw plugin
AGENTMEMORY_USER_ID=alice
```

MCP clients pass `userId` per-request in tool calls (no env needed):

```json
{
"tool": "memory_team_share",
"arguments": {
"itemId": "mem_1",
"itemType": "memory",
"userId": "alice"
}
}
```

Two modes:

| Mode | Write (team-share) | Read (team-feed, team-profile) | When to use |
|------|-------------------|-------------------------------|-------------|
| `shared` (default) | anyone can share as anyone | everyone sees all shared items | Collaborative team with full visibility |
| `private` | forced to configured userId | only your own items visible | Private workspace — each user sees only their own |

In `private` mode, the body `userId` override is **ignored** for all team operations. This prevents impersonation — users can only share and read as themselves.

Multi-agent + multi-user combined: each integration sets both `AGENT_ID` (which agent) and `AGENTMEMORY_USER_ID` (which person). The agent identity scopes regular memory operations; the user identity scopes team operations.

### Ports

agentmemory + iii-engine bind four ports by default. If a restart fails with `port in use`, this table tells you which process to look for.
Expand Down Expand Up @@ -1333,8 +1379,9 @@ Create `~/.agentmemory/.env`:

# Team
# TEAM_ID=
# USER_ID=
# USER_ID= # server-level default userId
# TEAM_MODE=private
# AGENTMEMORY_USER_ID= # per-integration override (set in integration .env, not here)

# Tool visibility: "core" (8 tools, lean fallback) or "all" (53 tools)
# AGENTMEMORY_TOOLS=core
Expand Down
13 changes: 12 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,23 @@ export function loadClaudeBridgeConfig(): ClaudeBridgeConfig {
export function loadTeamConfig(): TeamConfig | null {
const env = getMergedEnv();
const teamId = env["TEAM_ID"];
const userId = env["USER_ID"];
const userId = env["AGENTMEMORY_USER_ID"] || env["USER_ID"];
if (!teamId || !userId) return null;
const mode = env["TEAM_MODE"] === "shared" ? "shared" : "private";
return { teamId, userId, mode };
}

export function resolveTeamId(): string | undefined {
const env = getMergedEnv();
return env["TEAM_ID"] || undefined;
}

export function resolveUserId(override?: string): string | undefined {
const raw = override?.trim().slice(0, 128);
if (raw) return raw;
return getMergedEnv()["AGENTMEMORY_USER_ID"] || undefined;
}

// optional AGENT_ID env for multi-agent memory isolation.
// Returns null when unset so memory stays unscoped (legacy behavior).
// Trimmed + length-capped to keep KV writes well-formed.
Expand Down
54 changes: 40 additions & 14 deletions src/functions/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { KV, generateId } from "../state/schema.js";
import type { StateKV } from "../state/kv.js";
import { recordAudit } from "./audit.js";
import { logger } from "../logger.js";
import { resolveTeamId, resolveUserId } from "../config.js";

const VALID_ITEM_TYPES = new Set(["memory", "pattern", "observation"]);

Expand All @@ -23,6 +24,7 @@ export function registerTeamFunction(
itemType: "memory" | "pattern" | "observation";
sessionId?: string;
project?: string;
userId?: string;
}) => {
if (!data) {
return { success: false, error: "payload required" };
Expand Down Expand Up @@ -50,38 +52,53 @@ export function registerTeamFunction(
return { success: false, error: "Item not found" };
}

const teamId = resolveTeamId() ?? config.teamId;
// Private mode: force sharedBy to the configured userId.
// Prevents impersonation — users can only share as themselves.
const userId = config.mode === "private"
? config.userId
: (resolveUserId(data.userId) ?? config.userId);

const shared: TeamSharedItem = {
id: generateId("ts"),
sharedBy: config.userId,
sharedBy: userId,
sharedAt: new Date().toISOString(),
type: data.itemType,
content,
project: data.project || "",
visibility: "shared",
};

await kv.set(KV.teamShared(config.teamId), shared.id, shared);
await kv.set(KV.teamShared(teamId), shared.id, shared);

await recordAudit(kv, "share", "mem::team-share", [data.itemId], {
teamId: config.teamId,
userId: config.userId,
teamId,
userId,
itemType: data.itemType,
});

logger.info("Team share", {
teamId: config.teamId,
teamId,
itemId: data.itemId,
});
return { success: true, sharedItem: shared };
},
);

sdk.registerFunction("mem::team-feed",
async (data?: { limit?: number }) => {
async (data?: { limit?: number; userId?: string }) => {
const limit = data?.limit ?? 20;
const items = await kv.list<TeamSharedItem>(KV.teamShared(config.teamId));
const teamId = resolveTeamId() ?? config.teamId;
const items = await kv.list<TeamSharedItem>(KV.teamShared(teamId));

let filtered = items.filter((i) => i.visibility === "shared");

// Private mode: only show items shared by the configured user.
// Body userId is ignored — prevents reading other users' items.
if (config.mode === "private") {
filtered = filtered.filter((i) => i.sharedBy === config.userId);
}

const filtered = items.filter((i) => i.visibility === "shared");
const sorted = filtered
.sort(
(a, b) =>
Expand All @@ -93,8 +110,15 @@ export function registerTeamFunction(
},
);

sdk.registerFunction("mem::team-profile", async () => {
const items = await kv.list<TeamSharedItem>(KV.teamShared(config.teamId));
sdk.registerFunction("mem::team-profile", async (data?: { userId?: string }) => {
const teamId = resolveTeamId() ?? config.teamId;
let items = await kv.list<TeamSharedItem>(KV.teamShared(teamId));

// Private mode: only profile the configured user's activity.
// Body userId is ignored — prevents reading other users' profiles.
if (config.mode === "private") {
items = items.filter((i) => i.sharedBy === config.userId);
}

const members = [...new Set(items.map((i) => i.sharedBy))];

Expand Down Expand Up @@ -132,7 +156,7 @@ export function registerTeamFunction(
.map(([file, frequency]) => ({ file, frequency }));

const profile: TeamProfile = {
teamId: config.teamId,
teamId,
members,
topConcepts,
topFiles,
Expand All @@ -141,19 +165,21 @@ export function registerTeamFunction(
updatedAt: new Date().toISOString(),
};

await kv.set(KV.teamProfile(config.teamId), "profile", profile);
const auditActor = resolveUserId(data?.userId) ?? config.userId;

await kv.set(KV.teamProfile(teamId), "profile", profile);
await recordAudit(
kv,
"share",
"mem::team-profile",
["profile"],
{
teamId: config.teamId,
teamId,
members: members.length,
totalSharedItems: items.length,
},
undefined,
config.userId,
auditActor,
);
return profile;
});
Expand Down
23 changes: 17 additions & 6 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
} from "../types.js";
import { getVisibleTools } from "./tools-registry.js";
import { timingSafeCompare } from "../auth.js";
import { resolveUserId } from "../config.js";

type McpResponse = {
status_code: number;
Expand Down Expand Up @@ -505,10 +506,15 @@ export function registerMcpEndpoints(
};
}
try {
const result = await sdk.trigger({ function_id: "mem::team-share", payload: {
const resolvedUserId = resolveUserId(
typeof args.userId === "string" ? args.userId : undefined,
);
const payload: Record<string, unknown> = {
itemId: args.itemId,
itemType: args.itemType,
} });
...(resolvedUserId ? { userId: resolvedUserId } : {}),
};
const result = await sdk.trigger({ function_id: "mem::team-share", payload });
return {
status_code: 200,
body: {
Expand All @@ -524,7 +530,7 @@ export function registerMcpEndpoints(
content: [
{
type: "text",
text: "Team memory not enabled. Set TEAM_ID and USER_ID",
text: "Team memory not enabled. Set TEAM_ID and AGENTMEMORY_USER_ID (or USER_ID as a fallback)",
},
],
},
Expand All @@ -534,9 +540,14 @@ export function registerMcpEndpoints(

case "memory_team_feed": {
try {
const result = await sdk.trigger({ function_id: "mem::team-feed", payload: {
const resolvedUserId = resolveUserId(
typeof args.userId === "string" ? args.userId : undefined,
);
const payload: Record<string, unknown> = {
limit: typeof args.limit === "number" ? args.limit : 20,
} });
...(resolvedUserId ? { userId: resolvedUserId } : {}),
};
const result = await sdk.trigger({ function_id: "mem::team-feed", payload });
return {
status_code: 200,
body: {
Expand All @@ -552,7 +563,7 @@ export function registerMcpEndpoints(
content: [
{
type: "text",
text: "Team memory not enabled. Set TEAM_ID and USER_ID",
text: "Team memory not enabled. Set TEAM_ID and AGENTMEMORY_USER_ID (or USER_ID as a fallback)",
},
],
},
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/tools-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,10 @@ export const V040_TOOLS: McpToolDef[] = [
type: "string",
description: "Type: observation, memory, or pattern",
},
userId: {
type: "string",
description: "User identity for this operation. Overrides AGENTMEMORY_USER_ID env when set.",
},
},
required: ["itemId", "itemType"],
},
Expand All @@ -316,6 +320,10 @@ export const V040_TOOLS: McpToolDef[] = [
type: "object",
properties: {
limit: { type: "number", description: "Max items (default 20)" },
userId: {
type: "string",
description: "User identity for this operation. Overrides AGENTMEMORY_USER_ID env when set.",
},
},
},
},
Expand Down
74 changes: 74 additions & 0 deletions test/config-resolve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { resolveTeamId, resolveUserId } from "../src/config.js";

describe("resolveTeamId", () => {
const originalEnv = { ...process.env };

beforeEach(() => {
delete process.env.TEAM_ID;
delete process.env.AGENTMEMORY_USER_ID;
});

afterEach(() => {
Object.keys(process.env).forEach((k) => {
if (!(k in originalEnv)) delete process.env[k];
});
Object.assign(process.env, originalEnv);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("returns TEAM_ID from env", () => {
process.env.TEAM_ID = "my-team";
expect(resolveTeamId()).toBe("my-team");
});

it("returns undefined when TEAM_ID not set", () => {
expect(resolveTeamId()).toBeUndefined();
});
});

describe("resolveUserId", () => {
const originalEnv = { ...process.env };

beforeEach(() => {
delete process.env.AGENTMEMORY_USER_ID;
delete process.env.USER_ID;
});

afterEach(() => {
Object.keys(process.env).forEach((k) => {
if (!(k in originalEnv)) delete process.env[k];
});
Object.assign(process.env, originalEnv);
});

it("returns override when provided", () => {
process.env.AGENTMEMORY_USER_ID = "env-user";
expect(resolveUserId("override-user")).toBe("override-user");
});

it("falls back to AGENTMEMORY_USER_ID when no override", () => {
process.env.AGENTMEMORY_USER_ID = "env-user";
expect(resolveUserId()).toBe("env-user");
});

it("returns undefined when no override and no env", () => {
expect(resolveUserId()).toBeUndefined();
});

it("trims whitespace from override", () => {
expect(resolveUserId(" mama ")).toBe("mama");
});

it("truncates override to 128 chars", () => {
const long = "b".repeat(200);
const result = resolveUserId(long);
expect(result).toBeDefined();
expect(result!.length).toBe(128);
});

it("empty string override falls back to env", () => {
process.env.AGENTMEMORY_USER_ID = "env-user";
expect(resolveUserId("")).toBe("env-user");
expect(resolveUserId(" ")).toBe("env-user");
});
});
Loading