Skip to content

Commit e159a48

Browse files
committed
Add workspace namespace isolation
Introduce a first-class namespace/workspace boundary that is separate from the existing project field. This adds AGENTMEMORY_NAMESPACE and AGENTMEMORY_NAMESPACE_SCOPE, stamps namespace onto sessions, observations, memories, lessons, and project profiles, and enforces namespace-aware filtering across the core recall and API surfaces. Key behavior changes: - add namespace env/config loading with shared vs isolated modes - stamp namespace on write paths including session start, observe, remember, compress, and synthetic compression - enforce namespace filtering in mem::search, mem::smart-search, mem::context, and mem::enrich - filter REST list/read endpoints for sessions, observations, and memories in isolated mode - keep project as an intra-namespace identifier instead of the top-level workspace boundary - key project profiles by namespace+project so the same project slug can exist in multiple workspaces without collisions - extend lessons to carry namespace and avoid cross-namespace fingerprint collisions - document the feature in README and .env.example Validation: - focused namespace regression tests passed - build passed with npm run build - full unit suite was green except for one pre-existing unrelated fs-watcher test failure
1 parent f6f9e3c commit e159a48

24 files changed

Lines changed: 862 additions & 48 deletions

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,13 @@
141141
# TEAM_ID=acme
142142
# USER_ID=rohit
143143

144+
# Workspace / namespace isolation — use this when one agentmemory server
145+
# backs multiple higher-level environments (for example `work`, `personal`,
146+
# `research`). `project` stays project-local inside a namespace; namespace is
147+
# the stronger boundary across sessions, observations, memories, and profiles.
148+
# AGENTMEMORY_NAMESPACE=work
149+
# AGENTMEMORY_NAMESPACE_SCOPE=isolated # shared (default) | isolated
150+
144151
# -----------------------------------------------------------------------------
145152
# 7. Ports
146153
# -----------------------------------------------------------------------------

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,6 +1320,40 @@ Per-call override at the SDK / REST layer: every mutating endpoint (`/session/st
13201320

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

1323+
### Workspace namespaces (`AGENTMEMORY_NAMESPACE` + `AGENTMEMORY_NAMESPACE_SCOPE`)
1324+
1325+
If one agentmemory daemon serves multiple higher-level environments such as `work`, `personal`, or `research`, set a namespace on the server or per request.
1326+
1327+
```env
1328+
AGENTMEMORY_NAMESPACE=work
1329+
AGENTMEMORY_NAMESPACE_SCOPE=isolated # optional; default "shared"
1330+
```
1331+
1332+
This is intentionally separate from `project`:
1333+
1334+
- `namespace` = the top-level workspace boundary
1335+
- `project` = the project identifier inside that workspace
1336+
1337+
Examples:
1338+
1339+
- `namespace=work`, `project=thinpro`
1340+
- `namespace=personal`, `project=thinpro`
1341+
1342+
Those two projects can now coexist without sharing sessions, observations, memories, or cached project profiles.
1343+
1344+
Two modes:
1345+
1346+
| Mode | Tag writes | Filter recall | When to use |
1347+
|------|------------|---------------|-------------|
1348+
| `shared` (default) | yes | no | Auditability without automatic isolation. Callers can still filter by passing `namespace`. |
1349+
| `isolated` | yes | yes | Strict workspace separation. Reads default to the configured namespace unless the caller explicitly opts out with `namespace=*`. |
1350+
1351+
What gets tagged when `AGENTMEMORY_NAMESPACE` is set: `Session.namespace`, `RawObservation.namespace`, `CompressedObservation.namespace`, `Memory.namespace`, `ProjectProfile.namespace`, `Lesson.namespace`.
1352+
1353+
What gets filtered in isolated mode: `mem::search`, `mem::smart-search`, `mem::context`, `mem::enrich`, `/agentmemory/sessions`, `/agentmemory/observations`, `/agentmemory/memories`.
1354+
1355+
Per-call override at the SDK / REST layer: mutating endpoints such as `/session/start`, `/observe`, `/remember`, `/context`, `/search`, `/smart-search`, and `/enrich` accept a `namespace` field that overrides the env default for that call.
1356+
13231357
### Ports
13241358

13251359
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.

src/config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,30 @@ export function isAgentScopeIsolated(): boolean {
315315
return loadAgentScope()?.mode === "isolated";
316316
}
317317

318+
export function loadNamespaceScope(): {
319+
namespace: string;
320+
mode: "shared" | "isolated";
321+
} | null {
322+
const env = getMergedEnv();
323+
const raw = env["AGENTMEMORY_NAMESPACE"];
324+
if (!raw) return null;
325+
const namespace = raw.trim().slice(0, 128);
326+
if (!namespace) return null;
327+
const mode =
328+
env["AGENTMEMORY_NAMESPACE_SCOPE"] === "isolated"
329+
? "isolated"
330+
: "shared";
331+
return { namespace, mode };
332+
}
333+
334+
export function getNamespace(): string | undefined {
335+
return loadNamespaceScope()?.namespace;
336+
}
337+
338+
export function isNamespaceScopeIsolated(): boolean {
339+
return loadNamespaceScope()?.mode === "isolated";
340+
}
341+
318342
export function loadSnapshotConfig(): {
319343
enabled: boolean;
320344
interval: number;

src/functions/claude-bridge.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { KV } from "../state/schema.js";
66
import type { StateKV } from "../state/kv.js";
77
import { recordAudit } from "./audit.js";
88
import { logger } from "../logger.js";
9+
import { makeProjectProfileKey } from "../utils/namespace.js";
910

1011
function parseMemoryMd(content: string): {
1112
sections: Map<string, string>;
@@ -124,7 +125,10 @@ export function registerClaudeBridgeFunction(
124125
let projectSummary = "";
125126
if (config.projectPath) {
126127
const profile = await kv
127-
.get<{ summary?: string }>(KV.profiles, config.projectPath)
128+
.get<{ summary?: string }>(
129+
KV.profiles,
130+
makeProjectProfileKey(config.projectPath),
131+
)
128132
.catch(() => null);
129133
projectSummary = profile?.summary || "";
130134
}

src/functions/compress-synthetic.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,5 +102,6 @@ export function buildSyntheticCompression(
102102
if (raw.modality) result.modality = raw.modality;
103103
if (raw.imageData) result.imageData = raw.imageData;
104104
if (raw.agentId) result.agentId = raw.agentId;
105+
if (raw.namespace) result.namespace = raw.namespace;
105106
return result;
106107
}

src/functions/compress.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export function registerCompressFunction(
166166
...(imageDescription ? { imageDescription } : {}),
167167
...(data.raw.imageData ? { imageRef: data.raw.imageData } : {}),
168168
...(data.raw.agentId ? { agentId: data.raw.agentId } : {}),
169+
...(data.raw.namespace ? { namespace: data.raw.namespace } : {}),
169170
};
170171

171172
await kv.set(

src/functions/context.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
listPinnedSlots,
1818
renderPinnedContext,
1919
} from "./slots.js";
20+
import { makeProjectProfileKey } from "../utils/namespace.js";
2021

2122
function estimateTokens(text: string): number {
2223
return Math.ceil(text.length / 3);
@@ -36,16 +37,17 @@ export function registerContextFunction(
3637
tokenBudget: number,
3738
): void {
3839
sdk.registerFunction("mem::context",
39-
async (data: { sessionId: string; project: string; budget?: number }) => {
40+
async (data: { sessionId: string; project: string; namespace?: string; budget?: number }) => {
4041
const budget = data.budget || tokenBudget;
4142
const blocks: ContextBlock[] = [];
43+
const profileKey = makeProjectProfileKey(data.project, data.namespace);
4244

4345
const [pinnedSlots, profile, lessons] = await Promise.all([
4446
isSlotsEnabled()
4547
? listPinnedSlots(kv).catch(() => [] as MemorySlot[])
4648
: Promise.resolve([] as MemorySlot[]),
4749
kv
48-
.get<ProjectProfile>(KV.profiles, data.project)
50+
.get<ProjectProfile>(KV.profiles, profileKey)
4951
.catch(() => null),
5052
kv.list<Lesson>(KV.lessons).catch(() => [] as Lesson[]),
5153
]);
@@ -103,7 +105,12 @@ export function registerContextFunction(
103105
// 10 to keep the block bounded since the outer token-budget loop
104106
// below will drop the whole block if it doesn't fit. #457.
105107
const relevantLessons = lessons
106-
.filter((l) => !l.deleted && (!l.project || l.project === data.project))
108+
.filter(
109+
(l) =>
110+
!l.deleted &&
111+
(!l.project || l.project === data.project) &&
112+
(!data.namespace ? !l.namespace : l.namespace === data.namespace),
113+
)
107114
.sort((a, b) => {
108115
const scoreA = (a.project === data.project ? 1.5 : 1) * a.confidence;
109116
const scoreB = (b.project === data.project ? 1.5 : 1) * b.confidence;
@@ -134,7 +141,12 @@ export function registerContextFunction(
134141

135142
const allSessions = await kv.list<Session>(KV.sessions);
136143
const sessions = allSessions
137-
.filter((s) => s.project === data.project && s.id !== data.sessionId)
144+
.filter(
145+
(s) =>
146+
s.project === data.project &&
147+
s.id !== data.sessionId &&
148+
s.namespace === data.namespace,
149+
)
138150
.sort(
139151
(a, b) =>
140152
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
@@ -201,7 +213,10 @@ export function registerContextFunction(
201213
let usedTokens = 0;
202214
const selected: string[] = [];
203215
const accessedIds: string[] = [];
204-
const header = `<agentmemory-context project="${escapeXmlAttr(data.project)}">`;
216+
const namespaceAttr = data.namespace
217+
? ` namespace="${escapeXmlAttr(data.namespace)}"`
218+
: "";
219+
const header = `<agentmemory-context project="${escapeXmlAttr(data.project)}"${namespaceAttr}>`;
205220
const footer = `</agentmemory-context>`;
206221
usedTokens += estimateTokens(header) + estimateTokens(footer);
207222

@@ -219,7 +234,10 @@ export function registerContextFunction(
219234
}
220235

221236
if (selected.length === 0) {
222-
logger.info("No context available", { project: data.project });
237+
logger.info("No context available", {
238+
project: data.project,
239+
namespace: data.namespace,
240+
});
223241
return { context: "", blocks: 0, tokens: 0 };
224242
}
225243

src/functions/enrich.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Memory } from "../types.js";
33
import { KV } from "../state/schema.js";
44
import { StateKV } from "../state/kv.js";
55
import { logger } from "../logger.js";
6+
import { normalizeNamespace } from "../utils/namespace.js";
67

78
const MAX_CONTEXT_LENGTH = 4000;
89

@@ -23,11 +24,13 @@ export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void {
2324
terms?: string[];
2425
toolName?: string;
2526
project?: string;
27+
namespace?: string;
2628
}) => {
2729
const project =
2830
typeof data.project === "string" && data.project.trim().length > 0
2931
? data.project.trim()
3032
: undefined;
33+
const namespace = normalizeNamespace(data.namespace);
3134

3235
const parts: string[] = [];
3336

@@ -50,14 +53,15 @@ export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void {
5053
searchQueries.length > 0
5154
? sdk
5255
.trigger<
53-
{ query: string; limit: number; project?: string },
56+
{ query: string; limit: number; project?: string; namespace?: string },
5457
{ results: Array<{ observation: { narrative: string } }> }
5558
>({
5659
function_id: "mem::search",
5760
payload: {
5861
query: searchQueries.join(" "),
5962
limit: 5,
6063
...(project !== undefined && { project }),
64+
...(namespace !== undefined && { namespace }),
6165
},
6266
})
6367
.catch(() => ({ results: [] }))
@@ -71,6 +75,7 @@ export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void {
7175
(m) =>
7276
m.type === "bug" &&
7377
m.isLatest &&
78+
(!namespace ? !m.namespace : m.namespace === namespace) &&
7479
// Guard only when both sides have an explicit project; unscoped memories pass through.
7580
(!project || !m.project || m.project === project) &&
7681
m.files.some((f) =>
@@ -128,6 +133,7 @@ export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void {
128133
logger.info("Enrichment completed", {
129134
sessionId: data.sessionId,
130135
project,
136+
namespace,
131137
fileCount: data.files.length,
132138
contextLength: context.length,
133139
truncated,

src/functions/export-import.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type {
2727
import { normalizeAccessLog } from "./access-tracker.js";
2828
import { KV } from "../state/schema.js";
2929
import { StateKV } from "../state/kv.js";
30+
import { makeProjectProfileKey } from "../utils/namespace.js";
3031
import { VERSION } from "../version.js";
3132
import { recordAudit } from "./audit.js";
3233
import { logger } from "../logger.js";
@@ -62,7 +63,13 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
6263
}
6364

6465
const profiles: ProjectProfile[] = [];
65-
const uniqueProjects = [...new Set(paginatedSessions.map((s) => s.project))];
66+
const uniqueProjects = [
67+
...new Set(
68+
paginatedSessions.map((s) =>
69+
makeProjectProfileKey(s.project, s.namespace),
70+
),
71+
),
72+
];
6673
const profileResults = await Promise.all(
6774
uniqueProjects.map((project) =>
6875
kv.get<ProjectProfile>(KV.profiles, project).catch(() => null),
@@ -328,7 +335,10 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
328335
await kv.delete(KV.procedural, p.id);
329336
}
330337
for (const profile of await kv.list<ProjectProfile>(KV.profiles).catch(() => [])) {
331-
await kv.delete(KV.profiles, profile.project);
338+
await kv.delete(
339+
KV.profiles,
340+
makeProjectProfileKey(profile.project, profile.namespace),
341+
);
332342
}
333343
for (const a of await kv.list<AccessLogExport>(KV.accessLog).catch(() => [])) {
334344
await kv.delete(KV.accessLog, a.memoryId);
@@ -437,14 +447,21 @@ export function registerExportImportFunction(sdk: ISdk, kv: StateKV): void {
437447
for (const profile of importData.profiles) {
438448
if (strategy === "skip") {
439449
const existing = await kv
440-
.get<ProjectProfile>(KV.profiles, profile.project)
450+
.get<ProjectProfile>(
451+
KV.profiles,
452+
makeProjectProfileKey(profile.project, profile.namespace),
453+
)
441454
.catch(() => null);
442455
if (existing) {
443456
stats.skipped++;
444457
continue;
445458
}
446459
}
447-
await kv.set(KV.profiles, profile.project, profile);
460+
await kv.set(
461+
KV.profiles,
462+
makeProjectProfileKey(profile.project, profile.namespace),
463+
profile,
464+
);
448465
}
449466
}
450467

src/functions/lessons.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { StateKV } from "../state/kv.js";
33
import { KV, fingerprintId } from "../state/schema.js";
44
import type { Lesson } from "../types.js";
55
import { recordAudit } from "./audit.js";
6+
import { normalizeNamespace } from "../utils/namespace.js";
67

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

33-
const fp = fingerprintId("lsn", data.content.trim().toLowerCase());
35+
const namespace = normalizeNamespace(data.namespace);
36+
const fp = fingerprintId(
37+
"lsn",
38+
`${namespace ?? ""}::${data.project ?? ""}::${data.content.trim().toLowerCase()}`,
39+
);
3440
const existing = await kv.get<Lesson>(KV.lessons, fp);
3541

3642
if (existing && !existing.deleted) {
@@ -70,6 +76,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
7076
source: data.source || "manual",
7177
sourceIds: data.sourceIds || [],
7278
project: data.project,
79+
...(namespace ? { namespace } : {}),
7380
tags: data.tags || [],
7481
createdAt: now,
7582
updatedAt: now,
@@ -90,6 +97,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
9097
async (data: {
9198
query: string;
9299
project?: string;
100+
namespace?: string;
93101
minConfidence?: number;
94102
limit?: number;
95103
}) => {
@@ -110,6 +118,9 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
110118
if (data.project) {
111119
lessons = lessons.filter((l) => l.project === data.project);
112120
}
121+
if (data.namespace) {
122+
lessons = lessons.filter((l) => l.namespace === data.namespace);
123+
}
113124

114125
const scored = lessons
115126
.map((l) => {
@@ -153,6 +164,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
153164
sdk.registerFunction("mem::lesson-list",
154165
async (data: {
155166
project?: string;
167+
namespace?: string;
156168
source?: string;
157169
minConfidence?: number;
158170
limit?: number;
@@ -168,6 +180,9 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void {
168180
if (data.project) {
169181
lessons = lessons.filter((l) => l.project === data.project);
170182
}
183+
if (data.namespace) {
184+
lessons = lessons.filter((l) => l.namespace === data.namespace);
185+
}
171186
if (data.source) {
172187
lessons = lessons.filter((l) => l.source === data.source);
173188
}

0 commit comments

Comments
 (0)