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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@
# TEAM_ID=acme
# USER_ID=rohit

# Workspace / namespace isolation — use this when one agentmemory server
# backs multiple higher-level environments (for example `work`, `personal`,
# `research`). `project` stays project-local inside a namespace; namespace is
# the stronger boundary across sessions, observations, memories, and profiles.
# AGENTMEMORY_NAMESPACE=work
# AGENTMEMORY_NAMESPACE_SCOPE=isolated # shared (default) | isolated

# -----------------------------------------------------------------------------
# 7. Ports
# -----------------------------------------------------------------------------
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,40 @@ 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).

### Workspace namespaces (`AGENTMEMORY_NAMESPACE` + `AGENTMEMORY_NAMESPACE_SCOPE`)

If one agentmemory daemon serves multiple higher-level environments such as `work`, `personal`, or `research`, set a namespace on the server or per request.

```env
AGENTMEMORY_NAMESPACE=work
AGENTMEMORY_NAMESPACE_SCOPE=isolated # optional; default "shared"
```

This is intentionally separate from `project`:

- `namespace` = the top-level workspace boundary
- `project` = the project identifier inside that workspace

Examples:

- `namespace=work`, `project=thinpro`
- `namespace=personal`, `project=thinpro`

Those two projects can now coexist without sharing sessions, observations, memories, or cached project profiles.

Two modes:

| Mode | Tag writes | Filter recall | When to use |
|------|------------|---------------|-------------|
| `shared` (default) | yes | no | Auditability without automatic isolation. Callers can still filter by passing `namespace`. |
| `isolated` | yes | yes | Strict workspace separation. Reads default to the configured namespace unless the caller explicitly opts out with `namespace=*`. |

What gets tagged when `AGENTMEMORY_NAMESPACE` is set: `Session.namespace`, `RawObservation.namespace`, `CompressedObservation.namespace`, `Memory.namespace`, `ProjectProfile.namespace`, `Lesson.namespace`.

What gets filtered in isolated mode: `mem::search`, `mem::smart-search`, `mem::context`, `mem::enrich`, `/agentmemory/sessions`, `/agentmemory/observations`, `/agentmemory/memories`.

Per-call override at the SDK / REST layer: write endpoints such as `/session/start`, `/observe`, and `/remember`, plus read endpoints such as `/context`, `/search`, `/smart-search`, and `/enrich`, accept a `namespace` field that overrides the env default for that call.

### 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
24 changes: 24 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,30 @@ export function isAgentScopeIsolated(): boolean {
return loadAgentScope()?.mode === "isolated";
}

export function loadNamespaceScope(): {
namespace: string;
mode: "shared" | "isolated";
} | null {
const env = getMergedEnv();
const raw = env["AGENTMEMORY_NAMESPACE"];
if (!raw) return null;
const namespace = raw.trim().slice(0, 128);
if (!namespace) return null;
const mode =
env["AGENTMEMORY_NAMESPACE_SCOPE"] === "isolated"
? "isolated"
: "shared";
return { namespace, mode };
}

export function getNamespace(): string | undefined {
return loadNamespaceScope()?.namespace;
}

export function isNamespaceScopeIsolated(): boolean {
return loadNamespaceScope()?.mode === "isolated";
}

export function loadSnapshotConfig(): {
enabled: boolean;
interval: number;
Expand Down
7 changes: 6 additions & 1 deletion src/functions/claude-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { KV } from "../state/schema.js";
import type { StateKV } from "../state/kv.js";
import { recordAudit } from "./audit.js";
import { logger } from "../logger.js";
import { getNamespace } from "../config.js";
import { makeProjectProfileKey } from "../utils/namespace.js";

function parseMemoryMd(content: string): {
sections: Map<string, string>;
Expand Down Expand Up @@ -124,7 +126,10 @@ export function registerClaudeBridgeFunction(
let projectSummary = "";
if (config.projectPath) {
const profile = await kv
.get<{ summary?: string }>(KV.profiles, config.projectPath)
.get<{ summary?: string }>(
KV.profiles,
makeProjectProfileKey(config.projectPath, getNamespace()),
)
.catch(() => null);
projectSummary = profile?.summary || "";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
1 change: 1 addition & 0 deletions src/functions/compress-synthetic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,5 +102,6 @@ export function buildSyntheticCompression(
if (raw.modality) result.modality = raw.modality;
if (raw.imageData) result.imageData = raw.imageData;
if (raw.agentId) result.agentId = raw.agentId;
if (raw.namespace) result.namespace = raw.namespace;
return result;
}
1 change: 1 addition & 0 deletions src/functions/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export function registerCompressFunction(
...(imageDescription ? { imageDescription } : {}),
...(data.raw.imageData ? { imageRef: data.raw.imageData } : {}),
...(data.raw.agentId ? { agentId: data.raw.agentId } : {}),
...(data.raw.namespace ? { namespace: data.raw.namespace } : {}),
};

await kv.set(
Expand Down
31 changes: 25 additions & 6 deletions src/functions/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
listPinnedSlots,
renderPinnedContext,
} from "./slots.js";
import { makeProjectProfileKey, normalizeNamespace } from "../utils/namespace.js";

function estimateTokens(text: string): number {
return Math.ceil(text.length / 3);
Expand All @@ -36,16 +37,18 @@ export function registerContextFunction(
tokenBudget: number,
): void {
sdk.registerFunction("mem::context",
async (data: { sessionId: string; project: string; budget?: number }) => {
async (data: { sessionId: string; project: string; namespace?: string; budget?: number }) => {
const budget = data.budget || tokenBudget;
const blocks: ContextBlock[] = [];
const namespace = normalizeNamespace(data.namespace);
const profileKey = makeProjectProfileKey(data.project, namespace);

const [pinnedSlots, profile, lessons] = await Promise.all([
isSlotsEnabled()
? listPinnedSlots(kv).catch(() => [] as MemorySlot[])
: Promise.resolve([] as MemorySlot[]),
kv
.get<ProjectProfile>(KV.profiles, data.project)
.get<ProjectProfile>(KV.profiles, profileKey)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.catch(() => null),
kv.list<Lesson>(KV.lessons).catch(() => [] as Lesson[]),
]);
Expand Down Expand Up @@ -103,7 +106,12 @@ export function registerContextFunction(
// 10 to keep the block bounded since the outer token-budget loop
// below will drop the whole block if it doesn't fit. #457.
const relevantLessons = lessons
.filter((l) => !l.deleted && (!l.project || l.project === data.project))
.filter(
(l) =>
!l.deleted &&
(!l.project || l.project === data.project) &&
(!namespace ? !l.namespace : l.namespace === namespace),
)
.sort((a, b) => {
const scoreA = (a.project === data.project ? 1.5 : 1) * a.confidence;
const scoreB = (b.project === data.project ? 1.5 : 1) * b.confidence;
Expand Down Expand Up @@ -134,7 +142,12 @@ export function registerContextFunction(

const allSessions = await kv.list<Session>(KV.sessions);
const sessions = allSessions
.filter((s) => s.project === data.project && s.id !== data.sessionId)
.filter(
(s) =>
s.project === data.project &&
s.id !== data.sessionId &&
s.namespace === namespace,
)
.sort(
(a, b) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
Expand Down Expand Up @@ -201,7 +214,10 @@ export function registerContextFunction(
let usedTokens = 0;
const selected: string[] = [];
const accessedIds: string[] = [];
const header = `<agentmemory-context project="${escapeXmlAttr(data.project)}">`;
const namespaceAttr = namespace
? ` namespace="${escapeXmlAttr(namespace)}"`
: "";
const header = `<agentmemory-context project="${escapeXmlAttr(data.project)}"${namespaceAttr}>`;
const footer = `</agentmemory-context>`;
usedTokens += estimateTokens(header) + estimateTokens(footer);

Expand All @@ -219,7 +235,10 @@ export function registerContextFunction(
}

if (selected.length === 0) {
logger.info("No context available", { project: data.project });
logger.info("No context available", {
project: data.project,
namespace,
});
return { context: "", blocks: 0, tokens: 0 };
}

Expand Down
9 changes: 8 additions & 1 deletion src/functions/enrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { Memory } from "../types.js";
import { KV } from "../state/schema.js";
import { StateKV } from "../state/kv.js";
import { logger } from "../logger.js";
import { getNamespace } from "../config.js";
import { normalizeNamespace } from "../utils/namespace.js";

const MAX_CONTEXT_LENGTH = 4000;

Expand All @@ -23,11 +25,13 @@ export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void {
terms?: string[];
toolName?: string;
project?: string;
namespace?: string;
}) => {
const project =
typeof data.project === "string" && data.project.trim().length > 0
? data.project.trim()
: undefined;
const namespace = normalizeNamespace(data.namespace) ?? getNamespace();

const parts: string[] = [];

Expand All @@ -50,14 +54,15 @@ export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void {
searchQueries.length > 0
? sdk
.trigger<
{ query: string; limit: number; project?: string },
{ query: string; limit: number; project?: string; namespace?: string },
{ results: Array<{ observation: { narrative: string } }> }
>({
function_id: "mem::search",
payload: {
query: searchQueries.join(" "),
limit: 5,
...(project !== undefined && { project }),
...(namespace !== undefined && { namespace }),
},
})
.catch(() => ({ results: [] }))
Expand All @@ -71,6 +76,7 @@ export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void {
(m) =>
m.type === "bug" &&
m.isLatest &&
(!namespace ? !m.namespace : m.namespace === namespace) &&
// Guard only when both sides have an explicit project; unscoped memories pass through.
(!project || !m.project || m.project === project) &&
m.files.some((f) =>
Expand Down Expand Up @@ -128,6 +134,7 @@ export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void {
logger.info("Enrichment completed", {
sessionId: data.sessionId,
project,
namespace,
fileCount: data.files.length,
contextLength: context.length,
truncated,
Expand Down
25 changes: 21 additions & 4 deletions src/functions/export-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
import { normalizeAccessLog } from "./access-tracker.js";
import { KV } from "../state/schema.js";
import { StateKV } from "../state/kv.js";
import { makeProjectProfileKey } from "../utils/namespace.js";
import { VERSION } from "../version.js";
import { recordAudit } from "./audit.js";
import { logger } from "../logger.js";
Expand Down Expand Up @@ -62,7 +63,13 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
}

const profiles: ProjectProfile[] = [];
const uniqueProjects = [...new Set(paginatedSessions.map((s) => s.project))];
const uniqueProjects = [
...new Set(
paginatedSessions.map((s) =>
makeProjectProfileKey(s.project, s.namespace),
),
),
];
const profileResults = await Promise.all(
uniqueProjects.map((project) =>
kv.get<ProjectProfile>(KV.profiles, project).catch(() => null),
Expand Down Expand Up @@ -328,7 +335,10 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
await kv.delete(KV.procedural, p.id);
}
for (const profile of await kv.list<ProjectProfile>(KV.profiles).catch(() => [])) {
await kv.delete(KV.profiles, profile.project);
await kv.delete(
KV.profiles,
makeProjectProfileKey(profile.project, profile.namespace),
);
}
for (const a of await kv.list<AccessLogExport>(KV.accessLog).catch(() => [])) {
await kv.delete(KV.accessLog, a.memoryId);
Expand Down Expand Up @@ -437,14 +447,21 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
for (const profile of importData.profiles) {
if (strategy === "skip") {
const existing = await kv
.get<ProjectProfile>(KV.profiles, profile.project)
.get<ProjectProfile>(
KV.profiles,
makeProjectProfileKey(profile.project, profile.namespace),
)
.catch(() => null);
if (existing) {
stats.skipped++;
continue;
}
}
await kv.set(KV.profiles, profile.project, profile);
await kv.set(
KV.profiles,
makeProjectProfileKey(profile.project, profile.namespace),
profile,
);
}
}

Expand Down
19 changes: 18 additions & 1 deletion src/functions/lessons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { StateKV } from "../state/kv.js";
import { KV, fingerprintId } from "../state/schema.js";
import type { Lesson } from "../types.js";
import { recordAudit } from "./audit.js";
import { normalizeNamespace } from "../utils/namespace.js";

function reinforceLesson(lesson: Lesson): void {
const now = new Date().toISOString();
Expand All @@ -22,6 +23,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
context?: string;
confidence?: number;
project?: string;
namespace?: string;
tags?: string[];
source?: "crystal" | "manual" | "consolidation";
sourceIds?: string[];
Expand All @@ -30,7 +32,11 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
return { success: false, error: "content is required" };
}

const fp = fingerprintId("lsn", data.content.trim().toLowerCase());
const namespace = normalizeNamespace(data.namespace);
const fp = fingerprintId(
"lsn",
`${namespace ?? ""}::${data.project ?? ""}::${data.content.trim().toLowerCase()}`,
);
const existing = await kv.get<Lesson>(KV.lessons, fp);

if (existing && !existing.deleted) {
Expand Down Expand Up @@ -70,6 +76,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
source: data.source || "manual",
sourceIds: data.sourceIds || [],
project: data.project,
...(namespace ? { namespace } : {}),
tags: data.tags || [],
createdAt: now,
updatedAt: now,
Expand All @@ -90,6 +97,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
async (data: {
query: string;
project?: string;
namespace?: string;
minConfidence?: number;
limit?: number;
}) => {
Expand All @@ -110,6 +118,10 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
if (data.project) {
lessons = lessons.filter((l) => l.project === data.project);
}
const namespace = normalizeNamespace(data.namespace);
if (namespace) {
lessons = lessons.filter((l) => l.namespace === namespace);
}

const scored = lessons
.map((l) => {
Expand Down Expand Up @@ -153,6 +165,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
sdk.registerFunction("mem::lesson-list",
async (data: {
project?: string;
namespace?: string;
source?: string;
minConfidence?: number;
limit?: number;
Expand All @@ -168,6 +181,10 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
if (data.project) {
lessons = lessons.filter((l) => l.project === data.project);
}
const namespace = normalizeNamespace(data.namespace);
if (namespace) {
lessons = lessons.filter((l) => l.namespace === namespace);
}
if (data.source) {
lessons = lessons.filter((l) => l.source === data.source);
}
Expand Down
Loading