diff --git a/.env.example b/.env.example index 77ca0f3a3..f4a7dfe39 100644 --- a/.env.example +++ b/.env.example @@ -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 # ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index c4ec2c1e0..07f01fc5a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/config.ts b/src/config.ts index f68da2e31..3c8e4aa98 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; diff --git a/src/functions/claude-bridge.ts b/src/functions/claude-bridge.ts index 3aba76f56..8327d5332 100644 --- a/src/functions/claude-bridge.ts +++ b/src/functions/claude-bridge.ts @@ -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; @@ -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 || ""; } diff --git a/src/functions/compress-synthetic.ts b/src/functions/compress-synthetic.ts index 28d17e979..8d9a8e796 100644 --- a/src/functions/compress-synthetic.ts +++ b/src/functions/compress-synthetic.ts @@ -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; } diff --git a/src/functions/compress.ts b/src/functions/compress.ts index 0569555e0..fc64ac1f3 100644 --- a/src/functions/compress.ts +++ b/src/functions/compress.ts @@ -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( diff --git a/src/functions/context.ts b/src/functions/context.ts index 1e6102cf3..6cc6a7f63 100644 --- a/src/functions/context.ts +++ b/src/functions/context.ts @@ -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); @@ -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(KV.profiles, data.project) + .get(KV.profiles, profileKey) .catch(() => null), kv.list(KV.lessons).catch(() => [] as Lesson[]), ]); @@ -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; @@ -134,7 +142,12 @@ export function registerContextFunction( const allSessions = await kv.list(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(), @@ -201,7 +214,10 @@ export function registerContextFunction( let usedTokens = 0; const selected: string[] = []; const accessedIds: string[] = []; - const header = ``; + const namespaceAttr = namespace + ? ` namespace="${escapeXmlAttr(namespace)}"` + : ""; + const header = ``; const footer = ``; usedTokens += estimateTokens(header) + estimateTokens(footer); @@ -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 }; } diff --git a/src/functions/enrich.ts b/src/functions/enrich.ts index 032ec97b4..cbaf34770 100644 --- a/src/functions/enrich.ts +++ b/src/functions/enrich.ts @@ -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; @@ -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[] = []; @@ -50,7 +54,7 @@ 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", @@ -58,6 +62,7 @@ export function registerEnrichFunction(sdk: ISdk, kv: StateKV): void { query: searchQueries.join(" "), limit: 5, ...(project !== undefined && { project }), + ...(namespace !== undefined && { namespace }), }, }) .catch(() => ({ results: [] })) @@ -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) => @@ -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, diff --git a/src/functions/export-import.ts b/src/functions/export-import.ts index 327117b26..2d822627e 100644 --- a/src/functions/export-import.ts +++ b/src/functions/export-import.ts @@ -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"; @@ -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(KV.profiles, project).catch(() => null), @@ -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(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(KV.accessLog).catch(() => [])) { await kv.delete(KV.accessLog, a.memoryId); @@ -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(KV.profiles, profile.project) + .get( + 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, + ); } } diff --git a/src/functions/lessons.ts b/src/functions/lessons.ts index 9e69f464f..2a8898672 100644 --- a/src/functions/lessons.ts +++ b/src/functions/lessons.ts @@ -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(); @@ -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[]; @@ -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(KV.lessons, fp); if (existing && !existing.deleted) { @@ -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, @@ -90,6 +97,7 @@ export function registerLessonsFunctions(sdk: ISdk, kv: StateKV): void { async (data: { query: string; project?: string; + namespace?: string; minConfidence?: number; limit?: number; }) => { @@ -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) => { @@ -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; @@ -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); } diff --git a/src/functions/observe.ts b/src/functions/observe.ts index 2bf13d547..b2fe474f2 100644 --- a/src/functions/observe.ts +++ b/src/functions/observe.ts @@ -8,8 +8,9 @@ import { withKeyedLock } from "../state/keyed-mutex.js"; import { isAutoCompressEnabled } from "../config.js"; import { buildSyntheticCompression } from "./compress-synthetic.js"; import { getSearchIndex, vectorIndexAddGuarded } from "./search.js"; -import { getAgentId } from "../config.js"; +import { getAgentId, getNamespace } from "../config.js"; import { logger } from "../logger.js"; +import { normalizeNamespace } from "../utils/namespace.js"; export function extractImage(d: unknown): string | undefined { if (!d) return undefined; @@ -140,6 +141,7 @@ export function registerObserveFunction( // retroactively scoped by a later AGENT_ID export. const existingSession = await kv.get<{ agentId?: string; + namespace?: string; observationCount?: number; firstPrompt?: string; }>(KV.sessions, payload.sessionId); @@ -149,6 +151,12 @@ export function registerObserveFunction( if (inheritedAgentId) { raw.agentId = inheritedAgentId; } + const inheritedNamespace = existingSession + ? existingSession.namespace + : normalizeNamespace(payload.namespace) ?? getNamespace(); + if (inheritedNamespace) { + raw.namespace = inheritedNamespace; + } if (pendingImageData && (pendingImageData.startsWith("data:image/") || pendingImageData.startsWith("iVBORw0KGgo") || pendingImageData.startsWith("/9j/"))) { const { saveImageToDisk } = await import("../utils/image-store.js"); @@ -262,6 +270,7 @@ export function registerObserveFunction( await kv.set(KV.sessions, payload.sessionId, { id: payload.sessionId, project: payload.project, + ...(inheritedNamespace ? { namespace: inheritedNamespace } : {}), cwd: payload.cwd, startedAt: payload.timestamp ?? ts, updatedAt: ts, diff --git a/src/functions/profile.ts b/src/functions/profile.ts index 07881e922..37dbb7fba 100644 --- a/src/functions/profile.ts +++ b/src/functions/profile.ts @@ -8,18 +8,24 @@ import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; import { recordAudit } from "./audit.js"; import { logger } from "../logger.js"; +import { + makeProjectProfileKey, + normalizeNamespace, +} from "../utils/namespace.js"; export function registerProfileFunction(sdk: ISdk, kv: StateKV): void { sdk.registerFunction("mem::profile", - async (data: { project: string; refresh?: boolean } | undefined) => { + async (data: { project: string; namespace?: string; refresh?: boolean } | undefined) => { if (!data || typeof data.project !== "string" || !data.project.trim()) { return { success: false, error: "project is required" }; } const project = data.project.trim(); + const namespace = normalizeNamespace(data.namespace); + const profileKey = makeProjectProfileKey(project, namespace); if (!data.refresh) { const cached = await kv - .get(KV.profiles, project) + .get(KV.profiles, profileKey) .catch(() => null); if (cached) { const age = Date.now() - new Date(cached.updatedAt).getTime(); @@ -31,7 +37,7 @@ export function registerProfileFunction(sdk: ISdk, kv: StateKV): void { const sessions = await kv.list(KV.sessions); const projectSessions = sessions.filter( - (s) => s.project === project, + (s) => s.project === project && s.namespace === namespace, ); if (projectSessions.length === 0) { @@ -99,6 +105,7 @@ export function registerProfileFunction(sdk: ISdk, kv: StateKV): void { const profile: ProjectProfile = { project, + ...(namespace ? { namespace } : {}), updatedAt: new Date().toISOString(), topConcepts, topFiles, @@ -109,14 +116,15 @@ export function registerProfileFunction(sdk: ISdk, kv: StateKV): void { totalObservations: totalObs, }; - await kv.set(KV.profiles, project, profile); - await recordAudit(kv, "share", "mem::profile", [project], { + await kv.set(KV.profiles, profileKey, profile); + await recordAudit(kv, "share", "mem::profile", [profileKey], { sessionCount: projectSessions.length, totalObservations: totalObs, }); logger.info("Profile generated", { project, + namespace, sessions: projectSessions.length, observations: totalObs, }); diff --git a/src/functions/remember.ts b/src/functions/remember.ts index 5735b4f23..bbf650efd 100644 --- a/src/functions/remember.ts +++ b/src/functions/remember.ts @@ -9,6 +9,7 @@ import { recordAudit } from "./audit.js"; import { getSearchIndex, vectorIndexAddGuarded, vectorIndexRemove, flushIndexSave } from "./search.js"; import { getAgentId } from "../config.js"; import { logger } from "../logger.js"; +import { normalizeNamespace } from "../utils/namespace.js"; export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { sdk.registerFunction("mem::remember", @@ -21,6 +22,7 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { sourceObservationIds?: string[]; agentId?: string; project?: string; + namespace?: string; }) => { if ( !data.content || @@ -58,6 +60,7 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { typeof data.project === "string" && data.project.trim().length > 0 ? data.project.trim() : undefined; + const namespace = normalizeNamespace(data.namespace); return withKeyedLock("mem:remember", async () => { const existingMemories = await kv.list(KV.memories); @@ -74,6 +77,11 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { if (project && existing.project && existing.project !== project) { continue; } + // Namespace is the stronger boundary: fail closed so a namespaced + // memory never supersedes an unscoped legacy one (or vice versa). + if (namespace !== existing.namespace) { + continue; + } const similarity = jaccardSimilarity( lowerContent, existing.content.toLowerCase(), @@ -115,6 +123,7 @@ export function registerRememberFunction(sdk: ISdk, kv: StateKV): void { isLatest: true, ...(callAgentId ? { agentId: callAgentId } : {}), ...(project !== undefined && { project }), + ...(namespace !== undefined && { namespace }), }; if (data.ttlDays && typeof data.ttlDays === "number" && data.ttlDays > 0) { diff --git a/src/functions/search.ts b/src/functions/search.ts index df699a1a3..6adba060f 100644 --- a/src/functions/search.ts +++ b/src/functions/search.ts @@ -8,7 +8,12 @@ import type { EmbeddingProvider } from '../types.js' import { memoryToObservation } from '../state/memory-utils.js' import { recordAccessBatch } from './access-tracker.js' import { logger } from "../logger.js"; -import { getAgentId, isAgentScopeIsolated } from "../config.js"; +import { + getAgentId, + getNamespace, + isAgentScopeIsolated, + isNamespaceScopeIsolated, +} from "../config.js"; let index: SearchIndex | null = null let vectorIndex: VectorIndex | null = null @@ -326,6 +331,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { query: string limit?: number project?: string + namespace?: string cwd?: string format?: string token_budget?: number @@ -347,6 +353,14 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { effectiveLimit = Math.min(data.limit, MAX_LIMIT) } const projectFilter = typeof data.project === 'string' && data.project.trim().length > 0 ? data.project.trim() : undefined + const explicitNamespace = + typeof data.namespace === "string" && data.namespace.trim().length > 0 + ? data.namespace.trim() + : undefined + const wildcardNamespace = explicitNamespace === "*" + const namespaceFilter = wildcardNamespace + ? undefined + : explicitNamespace ?? (isNamespaceScopeIsolated() ? getNamespace() : undefined) const cwdFilter = typeof data.cwd === 'string' && data.cwd.trim().length > 0 ? data.cwd.trim() : undefined // #817: agent-scope isolation. mem::search backs REST /search, // memory_recall and recall_context. Without filtering here a @@ -408,7 +422,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { // doesn't carry it), so without the over-fetch isolated-mode // queries return underfilled pages when same-agent matches // rank lower than cross-agent ones in the hybrid score. - const filtering = !!(projectFilter || cwdFilter || filterAgentId) + const filtering = !!(projectFilter || namespaceFilter || cwdFilter || filterAgentId) const fetchLimit = filtering ? Math.max(effectiveLimit * 10, 100) : effectiveLimit const results = idx.search(query, fetchLimit) @@ -426,13 +440,18 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { // either has no KV.sessions entry or belongs to a different project. // When loadSession returns null we fall through to a KV.memories probe // so project-filtered search can include or exclude them correctly. - const memoryProjectCache = new Map() - const loadMemoryProject = async (obsId: string): Promise => { - if (memoryProjectCache.has(obsId)) return memoryProjectCache.get(obsId)! + const memoryMetaCache = new Map() + const loadMemoryMeta = async ( + obsId: string, + ): Promise<{ project: string | null; namespace: string | null }> => { + if (memoryMetaCache.has(obsId)) return memoryMetaCache.get(obsId)! const mem = await kv.get(KV.memories, obsId).catch(() => null) - const proj = mem?.project ?? null - memoryProjectCache.set(obsId, proj) - return proj + const meta = { + project: mem?.project ?? null, + namespace: mem?.namespace ?? null, + } + memoryMetaCache.set(obsId, meta) + return meta } // First pass: filter by session (sequential — benefits from session cache). @@ -451,6 +470,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { if (filtering) { const s = await loadSession(r.sessionId) if (s) { + if (namespaceFilter && s.namespace !== namespaceFilter) continue if (projectFilter && s.project !== projectFilter) continue if (cwdFilter && s.cwd !== cwdFilter) continue } else { @@ -468,9 +488,10 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { // block a result whose session we can no longer verify. // In both cases, a null memProject means "project unknown — treat as // unscoped and let it through" to preserve backward-compatibility. - if (projectFilter) { - const memProject = await loadMemoryProject(r.obsId) - if (memProject !== null && memProject !== projectFilter) continue + if (projectFilter || namespaceFilter) { + const memMeta = await loadMemoryMeta(r.obsId) + if (namespaceFilter && memMeta.namespace !== namespaceFilter) continue + if (projectFilter && memMeta.project !== null && memMeta.project !== projectFilter) continue } // cwd filter does not apply to unbound entries. } @@ -498,6 +519,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { for (let i = 0; i < candidates.length; i++) { const obs = obsResults[i] if (!obs) continue + if (namespaceFilter !== undefined && obs.namespace !== namespaceFilter) continue // #817: enforce agent-scope after the observation/memory is // loaded. The BM25 index doesn't carry agentId so the filter // happens post-lookup. Wildcard ("*") and no-isolation paths diff --git a/src/functions/smart-search.ts b/src/functions/smart-search.ts index 5d0147d9e..6515e6a64 100644 --- a/src/functions/smart-search.ts +++ b/src/functions/smart-search.ts @@ -12,7 +12,9 @@ import { withKeyedLock } from "../state/keyed-mutex.js"; import { recordAccessBatch } from "./access-tracker.js"; import { getAgentId, + getNamespace, isAgentScopeIsolated, + isNamespaceScopeIsolated, getFollowupWindowSeconds, } from "../config.js"; import { logger } from "../logger.js"; @@ -83,6 +85,7 @@ export function registerSmartSearchFunction( expandIds?: Array; limit?: number; project?: string; + namespace?: string; includeLessons?: boolean; // optional per-call agent filter for runtimes routing many // roles through one server. "*" opts out of the env-default @@ -128,6 +131,27 @@ export function registerSmartSearchFunction( 'Pass agentId: "*" to opt in to a wildcard read.', ); } + const explicitNamespace = + typeof data.namespace === "string" && data.namespace.trim().length > 0 + ? data.namespace.trim() + : undefined; + const wildcardNamespace = explicitNamespace === "*"; + const envNamespace = isNamespaceScopeIsolated() ? getNamespace() : undefined; + const filterNamespace = wildcardNamespace + ? undefined + : explicitNamespace ?? envNamespace; + if ( + isNamespaceScopeIsolated() && + !wildcardNamespace && + !explicitNamespace && + !envNamespace + ) { + throw new Error( + "mem::smart-search: AGENTMEMORY_NAMESPACE_SCOPE=isolated is set but " + + "no namespace is available (env AGENTMEMORY_NAMESPACE unset and no explicit " + + 'namespace in the call). Pass namespace: "*" to opt in to a wildcard read.', + ); + } if (data.expandIds && data.expandIds.length > 0) { const raw = data.expandIds.slice(0, 20); @@ -159,21 +183,24 @@ export function registerSmartSearchFunction( const scoped = filterAgentId ? expanded.filter((e) => e.observation.agentId === filterAgentId) : expanded; + const namespaceScoped = filterNamespace + ? scoped.filter((e) => e.observation.namespace === filterNamespace) + : scoped; void recordAccessBatch( kv, - scoped.map((e) => e.observation.id), + namespaceScoped.map((e) => e.observation.id), ); const truncated = data.expandIds.length > raw.length; logger.info("Smart search expanded", { requested: data.expandIds.length, attempted: raw.length, - returned: scoped.length, - filteredOutOfScope: expanded.length - scoped.length, + returned: namespaceScoped.length, + filteredOutOfScope: expanded.length - namespaceScoped.length, truncated, }); - return { mode: "expanded", results: scoped, truncated }; + return { mode: "expanded", results: namespaceScoped, truncated }; } if (!data.query || typeof data.query !== "string" || !data.query.trim()) { @@ -192,24 +219,29 @@ export function registerSmartSearchFunction( // is a defensible middle ground: enough headroom for a small // workload, capped at 300 so a 100-limit request never asks for // thousands of hits. - const overFetchLimit = filterAgentId + const overFetchLimit = filterAgentId || filterNamespace ? Math.min(limit * 3, 300) : limit; const [hybridResults, lessons] = await Promise.all([ searchFn(data.query, overFetchLimit), includeLessons - ? recallLessons(sdk, data.query, lessonLimit, data.project) + ? recallLessons(sdk, data.query, lessonLimit, data.project, filterNamespace) : Promise.resolve([]), ]); const filteredHybrid = filterAgentId ? hybridResults .filter((r) => r.observation.agentId === filterAgentId) + .slice(0, overFetchLimit) + : hybridResults.slice(0, overFetchLimit); + const namespaceFilteredHybrid = filterNamespace + ? filteredHybrid + .filter((r) => r.observation.namespace === filterNamespace) .slice(0, limit) - : hybridResults.slice(0, limit); + : filteredHybrid.slice(0, limit); - const compact: CompactSearchResult[] = filteredHybrid.map((r) => ({ + const compact: CompactSearchResult[] = namespaceFilteredHybrid.map((r) => ({ obsId: r.observation.id, sessionId: r.sessionId, title: r.observation.title, @@ -292,11 +324,12 @@ async function recallLessons( query: string, limit: number, project?: string, + namespace?: string, ): Promise { try { const result = (await sdk.trigger({ function_id: "mem::lesson-recall", - payload: { query, limit, project }, + payload: { query, limit, project, namespace }, })) as { success?: boolean; lessons?: Array }; if (!result?.success || !Array.isArray(result.lessons)) return []; return result.lessons.map((l) => ({ diff --git a/src/state/memory-utils.ts b/src/state/memory-utils.ts index aa0bcc5b8..c6a72c68c 100644 --- a/src/state/memory-utils.ts +++ b/src/state/memory-utils.ts @@ -13,6 +13,7 @@ export function memoryToObservation(memory: Memory): CompressedObservation { id: memory.id, sessionId: memory.sessionIds?.[0] ?? "memory", timestamp: memory.createdAt, + ...(memory.namespace ? { namespace: memory.namespace } : {}), type: "decision", title: memory.title, facts: [memory.content], diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 7b6c2bf2b..92861429c 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -21,8 +21,11 @@ import { detectEmbeddingProvider, detectLlmProviderKind, getAgentId, + getNamespace, isAgentScopeIsolated, + isNamespaceScopeIsolated, } from "../config.js"; +import { normalizeNamespace } from "../utils/namespace.js"; type Response = { status_code: number; @@ -116,6 +119,59 @@ function asNonEmptyString(value: unknown): string | null { return trimmed ? trimmed : null; } +function parseNamespaceInput( + value: unknown, + opts: { allowWildcard?: boolean } = {}, +): { namespace?: string; wildcard: boolean; invalid: boolean } { + if (value === undefined) { + return { namespace: undefined, wildcard: false, invalid: false }; + } + if (value === null || typeof value !== "string") { + return { namespace: undefined, wildcard: false, invalid: true }; + } + const trimmed = value.trim(); + if (opts.allowWildcard && trimmed === "*") { + return { namespace: undefined, wildcard: true, invalid: false }; + } + const namespace = normalizeNamespace(value); + if (namespace === undefined) { + return { namespace: undefined, wildcard: false, invalid: true }; + } + return { namespace, wildcard: false, invalid: false }; +} + +function invalidNamespaceResponse(): Response { + return { + status_code: 400, + body: { error: "namespace must be a non-empty string" }, + }; +} + +function resolveListNamespaceFilter( + parsed: { namespace?: string; wildcard: boolean; invalid: boolean }, +): { filterNamespace?: string } | Response { + if (parsed.invalid) { + return invalidNamespaceResponse(); + } + const configuredNamespace = isNamespaceScopeIsolated() ? getNamespace() : undefined; + if ( + isNamespaceScopeIsolated() && + !parsed.wildcard && + parsed.namespace === undefined && + !configuredNamespace + ) { + return { + status_code: 500, + body: { error: "namespace isolation is enabled but no namespace is configured" }, + }; + } + return { + filterNamespace: parsed.wildcard + ? undefined + : parsed.namespace ?? configuredNamespace, + }; +} + function parseOptionalFiniteNumber(value: unknown): number | undefined | null { if (value === undefined || value === null) return undefined; if (typeof value === "number") return Number.isFinite(value) ? value : null; @@ -289,8 +345,12 @@ export function registerApiTriggers( const hookType = asNonEmptyString(body.hookType); const sessionId = asNonEmptyString(body.sessionId); const project = asNonEmptyString(body.project); + const { namespace, invalid: invalidNamespace } = parseNamespaceInput(body.namespace); const cwd = asNonEmptyString(body.cwd); const timestamp = asNonEmptyString(body.timestamp); + if (invalidNamespace) { + return invalidNamespaceResponse(); + } if (!hookType || !sessionId || !project || !cwd || !timestamp) { return { status_code: 400, @@ -304,6 +364,7 @@ export function registerApiTriggers( hookType: hookType as HookPayload["hookType"], sessionId, project, + ...(namespace ? { namespace } : {}), cwd, timestamp, data: body.data, @@ -324,11 +385,15 @@ export function registerApiTriggers( sdk.registerFunction("api::context", async ( - req: ApiRequest<{ sessionId: string; project: string; budget?: number }>, + req: ApiRequest<{ sessionId: string; project: string; namespace?: string; budget?: number }>, ): Promise => { const body = (req.body ?? {}) as Record; const sessionId = asNonEmptyString(body.sessionId); const project = asNonEmptyString(body.project); + const { namespace, invalid: invalidNamespace } = parseNamespaceInput(body.namespace); + if (invalidNamespace) { + return invalidNamespaceResponse(); + } if (!sessionId || !project) { return { status_code: 400, @@ -342,10 +407,11 @@ export function registerApiTriggers( body: { error: "budget must be a positive integer" }, }; } - const payload: { sessionId: string; project: string; budget?: number } = { + const payload: { sessionId: string; project: string; namespace?: string; budget?: number } = { sessionId, project, }; + if (namespace !== undefined) payload.namespace = namespace; if (budget !== undefined) payload.budget = budget; const result = await sdk.trigger({ function_id: "mem::context", payload }); return { status_code: 200, body: result }; @@ -367,6 +433,7 @@ export function registerApiTriggers( query: string; limit?: number; project?: string; + namespace?: string; cwd?: string; format?: string; token_budget?: number; @@ -374,6 +441,10 @@ export function registerApiTriggers( }>, ): Promise => { const body = (req.body ?? {}) as Record; + const { namespace, invalid: invalidNamespace } = parseNamespaceInput(body.namespace); + if (invalidNamespace) { + return invalidNamespaceResponse(); + } const queryAgentId = typeof (req as { query_params?: Record }) .query_params?.["agentId"] === "string" @@ -426,6 +497,7 @@ export function registerApiTriggers( query: body.query.trim(), limit: body.limit as number | undefined, project: body.project as string | undefined, + namespace, cwd: body.cwd as string | undefined, format: typeof body.format === "string" @@ -559,11 +631,16 @@ export function registerApiTriggers( sdk.registerFunction("api::session::start", async ( - req: ApiRequest<{ sessionId: string; project: string; cwd: string }>, + req: ApiRequest<{ sessionId: string; project: string; namespace?: string; cwd: string }>, ): Promise => { const body = (req.body ?? {}) as Record; const sessionId = asNonEmptyString(body.sessionId); const project = asNonEmptyString(body.project); + const { namespace: explicitNamespace, invalid: invalidNamespace } = parseNamespaceInput(body.namespace); + if (invalidNamespace) { + return invalidNamespaceResponse(); + } + const namespace = explicitNamespace ?? getNamespace(); const cwd = asNonEmptyString(body.cwd); if (!sessionId || !project || !cwd) { return { @@ -585,6 +662,7 @@ export function registerApiTriggers( const session: Session = { id: sessionId, project, + ...(namespace ? { namespace } : {}), cwd, startedAt: new Date().toISOString(), status: "active", @@ -595,9 +673,12 @@ export function registerApiTriggers( }; await kv.set(KV.sessions, sessionId, session); const contextResult = await sdk.trigger< - { sessionId: string; project: string }, + { sessionId: string; project: string; namespace?: string }, { context: string } - >({ function_id: "mem::context", payload: { sessionId, project } }); + >({ + function_id: "mem::context", + payload: { sessionId, project, ...(namespace ? { namespace } : {}) }, + }); return { status_code: 200, body: { session, context: contextResult.context }, @@ -809,6 +890,13 @@ export function registerApiTriggers( const authErr = checkAuth(req, secret); if (authErr) return authErr; const sessions = await kv.list(KV.sessions); + const namespaceResult = resolveListNamespaceFilter( + parseNamespaceInput(req.query_params?.["namespace"], { allowWildcard: true }), + ); + if ("status_code" in namespaceResult) { + return namespaceResult; + } + const { filterNamespace } = namespaceResult; const normalizedAgentId = typeof req.query_params?.["agentId"] === "string" ? req.query_params["agentId"].trim() @@ -820,9 +908,12 @@ export function registerApiTriggers( ? undefined : explicitAgentId ?? (isAgentScopeIsolated() ? getAgentId() : undefined); - const filtered = filterAgentId + let filtered = filterAgentId ? sessions.filter((s) => s.agentId === filterAgentId) : sessions; + if (filterNamespace !== undefined) { + filtered = filtered.filter((s) => s.namespace === filterNamespace); + } const summaries = await Promise.all( filtered.map((s) => kv.get(KV.summaries, s.id).catch(() => null), @@ -850,6 +941,13 @@ export function registerApiTriggers( const observations = await kv.list( KV.observations(sessionId), ); + const namespaceResult = resolveListNamespaceFilter( + parseNamespaceInput(req.query_params?.["namespace"], { allowWildcard: true }), + ); + if ("status_code" in namespaceResult) { + return namespaceResult; + } + const { filterNamespace } = namespaceResult; const normalizedAgentId = typeof req.query_params?.["agentId"] === "string" ? req.query_params["agentId"].trim() @@ -861,9 +959,12 @@ export function registerApiTriggers( ? undefined : explicitAgentId ?? (isAgentScopeIsolated() ? getAgentId() : undefined); - const filtered = filterAgentId + let filtered = filterAgentId ? observations.filter((o) => o.agentId === filterAgentId) : observations; + if (filterNamespace !== undefined) { + filtered = filtered.filter((o) => o.namespace === filterNamespace); + } return { status_code: 200, body: { observations: filtered } }; }, ); @@ -897,6 +998,7 @@ export function registerApiTriggers( terms?: string[]; toolName?: string; project?: string; + namespace?: string; }>, ): Promise => { const authErr = checkAuth(req, secret); @@ -934,6 +1036,15 @@ export function registerApiTriggers( body: { error: "project must be a non-empty string" }, }; } + if ( + req.body.namespace !== undefined && + (typeof req.body.namespace !== "string" || !req.body.namespace.trim()) + ) { + return { + status_code: 400, + body: { error: "namespace must be a non-empty string" }, + }; + } const result = await sdk.trigger({ function_id: "mem::enrich", payload: { @@ -942,6 +1053,9 @@ export function registerApiTriggers( ...(req.body.terms !== undefined && { terms: req.body.terms }), ...(req.body.toolName !== undefined && { toolName: req.body.toolName }), ...(req.body.project !== undefined && { project: req.body.project }), + ...(req.body.namespace !== undefined && { + namespace: normalizeNamespace(req.body.namespace), + }), }, }); return { status_code: 200, body: result }; @@ -963,6 +1077,7 @@ export function registerApiTriggers( ttlDays?: number; sourceObservationIds?: string[]; project?: string; + namespace?: string; }>, ): Promise => { const authErr = checkAuth(req, secret); @@ -980,6 +1095,12 @@ export function registerApiTriggers( ) { return { status_code: 400, body: { error: "project must be a non-empty string" } }; } + if ( + req.body.namespace !== undefined && + (typeof req.body.namespace !== "string" || !req.body.namespace.trim()) + ) { + return { status_code: 400, body: { error: "namespace must be a non-empty string" } }; + } const result = await sdk.trigger({ function_id: "mem::remember", payload: { @@ -990,6 +1111,9 @@ export function registerApiTriggers( ...(req.body.ttlDays !== undefined && { ttlDays: req.body.ttlDays }), ...(req.body.sourceObservationIds !== undefined && { sourceObservationIds: req.body.sourceObservationIds }), ...(req.body.project !== undefined && { project: req.body.project }), + ...(req.body.namespace !== undefined && { + namespace: normalizeNamespace(req.body.namespace), + }), }, }); return { status_code: 201, body: result }; @@ -1127,6 +1251,7 @@ export function registerApiTriggers( expandIds?: Array; limit?: number; project?: string; + namespace?: string; includeLessons?: boolean; agentId?: string; sessionId?: string; @@ -1135,6 +1260,10 @@ export function registerApiTriggers( ): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; + const parsedNamespace = parseNamespaceInput(req.body?.namespace); + if (parsedNamespace.invalid) { + return invalidNamespaceResponse(); + } if ( !req.body?.query && (!req.body?.expandIds || req.body.expandIds.length === 0) @@ -1159,6 +1288,7 @@ export function registerApiTriggers( expandIds: req.body?.expandIds, limit: req.body?.limit, project: req.body?.project, + namespace: parsedNamespace.namespace, includeLessons: req.body?.includeLessons, agentId: req.body?.agentId, sessionId: req.body?.sessionId, @@ -1241,7 +1371,17 @@ export function registerApiTriggers( body: { error: "project query param is required" }, }; } - const result = await sdk.trigger({ function_id: "mem::profile", payload: { project } }); + const parsedNamespace = parseNamespaceInput(req.query_params?.["namespace"]); + if (parsedNamespace.invalid) { + return invalidNamespaceResponse(); + } + const result = await sdk.trigger({ + function_id: "mem::profile", + payload: { + project, + namespace: parsedNamespace.namespace, + }, + }); return { status_code: 200, body: result }; }, ); @@ -1831,6 +1971,13 @@ export function registerApiTriggers( if (authErr) return authErr; const memories = await kv.list(KV.memories); const latest = req.query_params?.["latest"] === "true"; + const namespaceResult = resolveListNamespaceFilter( + parseNamespaceInput(req.query_params?.["namespace"], { allowWildcard: true }), + ); + if ("status_code" in namespaceResult) { + return namespaceResult; + } + const { filterNamespace } = namespaceResult; // agentId filter. Request param wins, env AGENT_ID (when // scope=isolated) is the fallback. Shared mode keeps the tag but // does not restrict the list endpoint. Pass agentId=* to opt out @@ -1849,6 +1996,9 @@ export function registerApiTriggers( ? undefined : explicitAgentId ?? (isAgentScopeIsolated() ? getAgentId() : undefined); let filtered = latest ? memories.filter((m) => m.isLatest) : memories; + if (filterNamespace !== undefined) { + filtered = filtered.filter((m) => m.namespace === filterNamespace); + } if (filterAgentId) { filtered = filtered.filter( (m) => diff --git a/src/triggers/events.ts b/src/triggers/events.ts index e38b58db4..4e5cfe431 100644 --- a/src/triggers/events.ts +++ b/src/triggers/events.ts @@ -3,16 +3,19 @@ import type { CompressedObservation, HookPayload, Session } from "../types.js"; import { KV, STREAM } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; import { isReflectEnabled } from "../functions/slots.js"; -import { isGraphExtractionEnabled } from "../config.js"; +import { getNamespace, isGraphExtractionEnabled } from "../config.js"; import { logger } from "../logger.js"; +import { normalizeNamespace } from "../utils/namespace.js"; export function registerEventTriggers(sdk: ISdk, kv: StateKV): void { sdk.registerFunction( "event::session::started", - async (data: { sessionId: string; project: string; cwd: string }) => { + async (data: { sessionId: string; project: string; namespace?: string; cwd: string }) => { + const namespace = normalizeNamespace(data.namespace) ?? getNamespace(); const session: Session = { id: data.sessionId, project: data.project, + ...(namespace ? { namespace } : {}), cwd: data.cwd, startedAt: new Date().toISOString(), status: "active", @@ -20,11 +23,15 @@ export function registerEventTriggers(sdk: ISdk, kv: StateKV): void { }; await kv.set(KV.sessions, data.sessionId, session); const contextResult = await sdk.trigger< - { sessionId: string; project: string }, + { sessionId: string; project: string; namespace?: string }, { context: string } >({ function_id: "mem::context", - payload: { sessionId: data.sessionId, project: data.project }, + payload: { + sessionId: data.sessionId, + project: data.project, + ...(namespace ? { namespace } : {}), + }, }); return { session, context: contextResult.context }; }, diff --git a/src/types.ts b/src/types.ts index 6797dfaf9..53db45e5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export interface Session { id: string; project: string; + namespace?: string; cwd: string; startedAt: string; endedAt?: string; @@ -31,6 +32,7 @@ export interface RawObservation { id: string; sessionId: string; timestamp: string; + namespace?: string; hookType: HookType; toolName?: string; toolInput?: unknown; @@ -47,6 +49,7 @@ export interface CompressedObservation { id: string; sessionId: string; timestamp: string; + namespace?: string; type: ObservationType; title: string; subtitle?: string; @@ -84,6 +87,7 @@ export interface Memory { id: string; createdAt: string; updatedAt: string; + namespace?: string; type: "pattern" | "preference" | "architecture" | "bug" | "workflow" | "fact"; title: string; content: string; @@ -134,6 +138,7 @@ export interface HookPayload { hookType: HookType; sessionId: string; project: string; + namespace?: string; cwd: string; timestamp: string; data: unknown; @@ -277,6 +282,7 @@ export interface CompactLessonResult { score: number; createdAt: string; project?: string; + namespace?: string; tags: string[]; } @@ -288,6 +294,7 @@ export interface TimelineEntry { export interface ProjectProfile { project: string; + namespace?: string; updatedAt: string; topConcepts: Array<{ concept: string; frequency: number }>; topFiles: Array<{ file: string; frequency: number }>; @@ -807,6 +814,7 @@ export interface Lesson { source: "crystal" | "manual" | "consolidation"; sourceIds: string[]; project?: string; + namespace?: string; tags: string[]; createdAt: string; updatedAt: string; diff --git a/src/utils/namespace.ts b/src/utils/namespace.ts new file mode 100644 index 000000000..83b1297b4 --- /dev/null +++ b/src/utils/namespace.ts @@ -0,0 +1,13 @@ +export function normalizeNamespace(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, 128); +} + +export function makeProjectProfileKey( + project: string, + namespace?: string, +): string { + return namespace ? `ns:${namespace}::${project}` : project; +} diff --git a/test/agent-isolation-search.test.ts b/test/agent-isolation-search.test.ts index 929407b59..48f9e26af 100644 --- a/test/agent-isolation-search.test.ts +++ b/test/agent-isolation-search.test.ts @@ -20,11 +20,15 @@ vi.mock("../src/functions/access-tracker.js", () => ({ const configState = { agentId: undefined as string | undefined, isolated: false, + namespace: undefined as string | undefined, + namespaceIsolated: false, }; vi.mock("../src/config.js", () => ({ getAgentId: () => configState.agentId, + getNamespace: () => configState.namespace, isAgentScopeIsolated: () => configState.isolated, + isNamespaceScopeIsolated: () => configState.namespaceIsolated, })); import { diff --git a/test/api-namespace-isolation.test.ts b/test/api-namespace-isolation.test.ts new file mode 100644 index 000000000..6fac1a001 --- /dev/null +++ b/test/api-namespace-isolation.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +const configState = { + namespace: "work", + namespaceIsolated: true, +}; + +vi.mock("../src/config.js", () => ({ + detectEmbeddingProvider: vi.fn(() => null), + detectLlmProviderKind: vi.fn(() => "noop"), + getAgentId: vi.fn(() => undefined), + getNamespace: vi.fn(() => configState.namespace), + getStandalonePersistPath: vi.fn(() => "/tmp/standalone.json"), + isAgentScopeIsolated: vi.fn(() => false), + isAutoCompressEnabled: vi.fn(() => false), + isConsolidationEnabled: vi.fn(() => false), + isContextInjectionEnabled: vi.fn(() => false), + isGraphExtractionEnabled: vi.fn(() => false), + isNamespaceScopeIsolated: vi.fn(() => configState.namespaceIsolated), + isReflectEnabled: vi.fn(() => false), + isSlotsEnabled: vi.fn(() => false), +})); + +vi.mock("../src/health/monitor.js", () => ({ + getLatestHealth: vi.fn(() => null), +})); + +vi.mock("../src/viewer/server.js", () => ({ + getBoundViewerPort: vi.fn(() => 3113), + getViewerSkipped: vi.fn(() => false), +})); + +vi.mock("../src/viewer/document.js", () => ({ + renderViewerDocument: vi.fn(() => ""), +})); + +import { registerApiTriggers } from "../src/triggers/api.js"; +import { KV } from "../src/state/schema.js"; +import type { Memory, Session } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + update: async (scope: string, key: string, updates: Array<{ path: string; value: unknown }>) => { + const current = (store.get(scope)?.get(key) as Record) ?? {}; + const next = { ...current }; + for (const update of updates) next[update.path] = update.value; + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, next); + return next; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => + (Array.from(store.get(scope)?.values() ?? []) as T[]), + }; +} + +function mockSdk() { + const functions = new Map(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + const fn = functions.get(id); + if (!fn) throw new Error(`No function registered: ${id}`); + return fn(payload); + }, + }; +} + +describe("api namespace isolation", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerApiTriggers(sdk as never, kv as never); + + const sessions: Session[] = [ + { + id: "sess-work", + project: "shared", + namespace: "work", + cwd: "/repo/work", + startedAt: "2026-01-01T00:00:00Z", + status: "active", + observationCount: 0, + }, + { + id: "sess-personal", + project: "shared", + namespace: "personal", + cwd: "/repo/personal", + startedAt: "2026-01-01T00:00:00Z", + status: "active", + observationCount: 0, + }, + ]; + for (const session of sessions) { + await kv.set(KV.sessions, session.id, session); + } + + const memories: Memory[] = [ + { + id: "mem-work", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + namespace: "work", + project: "shared", + type: "fact", + title: "work memory", + content: "work memory", + concepts: [], + files: [], + sessionIds: [], + strength: 7, + version: 1, + isLatest: true, + }, + { + id: "mem-personal", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + namespace: "personal", + project: "shared", + type: "fact", + title: "personal memory", + content: "personal memory", + concepts: [], + files: [], + sessionIds: [], + strength: 7, + version: 1, + isLatest: true, + }, + ]; + for (const memory of memories) { + await kv.set(KV.memories, memory.id, memory); + } + }); + + it("session/start stamps the default namespace from config", async () => { + sdk.registerFunction("mem::context", async () => ({ context: "" })); + + const result = (await sdk.trigger("api::session::start", { + body: { + sessionId: "sess-new", + project: "shared", + cwd: "/repo/new", + }, + })) as { body: { session: Session } }; + + expect(result.body.session.namespace).toBe("work"); + const stored = await kv.get(KV.sessions, "sess-new"); + expect(stored?.namespace).toBe("work"); + }); + + it("sessions list is filtered by the active namespace in isolated mode", async () => { + const result = (await sdk.trigger("api::sessions", { + query_params: {}, + })) as { body: { sessions: Session[] } }; + + expect(result.body.sessions).toHaveLength(1); + expect(result.body.sessions[0]?.namespace).toBe("work"); + }); + + it("sessions list supports namespace wildcard override", async () => { + const result = (await sdk.trigger("api::sessions", { + query_params: { namespace: "*" }, + })) as { body: { sessions: Session[] } }; + + expect(result.body.sessions).toHaveLength(2); + }); + + it("memories list is filtered by the active namespace in isolated mode", async () => { + const result = (await sdk.trigger("api::memories", { + query_params: {}, + })) as { body: { memories: Memory[] } }; + + expect(result.body.memories).toHaveLength(1); + expect(result.body.memories[0]?.namespace).toBe("work"); + }); + + it("memories list supports namespace wildcard override", async () => { + const result = (await sdk.trigger("api::memories", { + query_params: { namespace: "*" }, + })) as { body: { memories: Memory[] } }; + + expect(result.body.memories).toHaveLength(2); + }); +}); diff --git a/test/cross-project-isolation.test.ts b/test/cross-project-isolation.test.ts index 6d76da3e8..aac64c586 100644 --- a/test/cross-project-isolation.test.ts +++ b/test/cross-project-isolation.test.ts @@ -19,7 +19,9 @@ vi.mock("../src/functions/access-tracker.js", () => ({ vi.mock("../src/config.js", () => ({ getAgentId: () => undefined, + getNamespace: () => undefined, isAgentScopeIsolated: () => false, + isNamespaceScopeIsolated: () => false, })); import { registerRememberFunction } from "../src/functions/remember.js"; diff --git a/test/namespace-isolation.test.ts b/test/namespace-isolation.test.ts new file mode 100644 index 000000000..97a34d911 --- /dev/null +++ b/test/namespace-isolation.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../src/state/keyed-mutex.js", () => ({ + withKeyedLock: (_key: string, fn: () => Promise) => fn(), +})); + +vi.mock("../src/functions/audit.js", () => ({ + recordAudit: vi.fn(), +})); + +vi.mock("../src/functions/access-tracker.js", () => ({ + recordAccessBatch: vi.fn(), + deleteAccessLog: vi.fn(), +})); + +const configState = { + namespace: "work", + namespaceIsolated: true, +}; + +vi.mock("../src/config.js", () => ({ + getAgentId: () => undefined, + getEnvVar: () => undefined, + getNamespace: () => configState.namespace, + isAgentScopeIsolated: () => false, + isNamespaceScopeIsolated: () => configState.namespaceIsolated, +})); + +import { registerContextFunction } from "../src/functions/context.js"; +import { registerEnrichFunction } from "../src/functions/enrich.js"; +import { registerProfileFunction } from "../src/functions/profile.js"; +import { + getSearchIndex, + registerSearchFunction, + setIndexPersistence, +} from "../src/functions/search.js"; +import { registerRememberFunction } from "../src/functions/remember.js"; +import { KV } from "../src/state/schema.js"; +import type { CompressedObservation, Session } from "../src/types.js"; + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => + (store.get(scope)?.get(key) as T) ?? null, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + update: async (scope: string, key: string, updates: Array<{ path: string; value: unknown }>) => { + const current = (store.get(scope)?.get(key) as Record) ?? {}; + const next = { ...current }; + for (const update of updates) next[update.path] = update.value; + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, next); + return next; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => + (Array.from(store.get(scope)?.values() ?? []) as T[]), + }; +} + +function mockSdk() { + const functions = new Map(); + const triggerOverrides = new Map(); + return { + registerFunction: (id: string, handler: Function) => { + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + if (triggerOverrides.has(id)) return triggerOverrides.get(id)!(payload); + const fn = functions.get(id); + if (!fn) throw new Error(`No function registered: ${id}`); + return fn(payload); + }, + overrideTrigger: (id: string, handler: Function) => { + triggerOverrides.set(id, handler); + }, + }; +} + +describe("namespace isolation", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + setIndexPersistence(null); + getSearchIndex().clear(); + + registerRememberFunction(sdk as never, kv as never); + registerSearchFunction(sdk as never, kv as never); + registerEnrichFunction(sdk as never, kv as never); + registerContextFunction(sdk as never, kv as never, 2000); + registerProfileFunction(sdk as never, kv as never); + + sdk.overrideTrigger("mem::file-context", async () => ({ context: "" })); + + const sessions: Session[] = [ + { + id: "sess-work", + project: "shared-project", + namespace: "work", + cwd: "/repo/work", + startedAt: "2026-01-01T00:00:00Z", + status: "active", + observationCount: 1, + }, + { + id: "sess-personal", + project: "shared-project", + namespace: "personal", + cwd: "/repo/personal", + startedAt: "2026-01-02T00:00:00Z", + status: "active", + observationCount: 1, + }, + { + id: "sess-work-2", + project: "shared-project", + namespace: "work", + cwd: "/repo/work-2", + startedAt: "2026-01-03T00:00:00Z", + status: "active", + observationCount: 1, + }, + ]; + for (const session of sessions) { + await kv.set(KV.sessions, session.id, session); + } + + const workObs: CompressedObservation = { + id: "obs-work", + sessionId: "sess-work", + timestamp: "2026-01-01T00:00:00Z", + namespace: "work", + type: "decision", + title: "Work auth fix", + facts: ["Trim JWT header"], + narrative: "Work namespace fixed auth middleware trimming.", + concepts: ["auth"], + files: ["src/auth.ts"], + importance: 8, + }; + const personalObs: CompressedObservation = { + id: "obs-personal", + sessionId: "sess-personal", + timestamp: "2026-01-02T00:00:00Z", + namespace: "personal", + type: "decision", + title: "Personal auth fix", + facts: ["Trim local token"], + narrative: "Personal namespace fixed local auth middleware trimming.", + concepts: ["auth"], + files: ["src/auth.ts"], + importance: 8, + }; + const workObs2: CompressedObservation = { + id: "obs-work-2", + sessionId: "sess-work-2", + timestamp: "2026-01-03T00:00:00Z", + namespace: "work", + type: "decision", + title: "Work auth fix 2", + facts: ["Trim JWT header in sibling workspace"], + narrative: "Work namespace sibling session fixed auth middleware trimming.", + concepts: ["auth"], + files: ["src/auth.ts"], + importance: 8, + }; + await kv.set(KV.observations("sess-work"), workObs.id, workObs); + await kv.set(KV.observations("sess-personal"), personalObs.id, personalObs); + await kv.set(KV.observations("sess-work-2"), workObs2.id, workObs2); + }); + + it("searches only within the active namespace when scope is isolated", async () => { + await sdk.trigger("mem::remember", { + content: "Work-only JWT whitespace bug in auth middleware", + type: "bug", + files: ["src/auth.ts"], + project: "shared-project", + namespace: "work", + }); + await sdk.trigger("mem::remember", { + content: "Personal-only JWT whitespace bug in auth middleware", + type: "bug", + files: ["src/auth.ts"], + project: "shared-project", + namespace: "personal", + }); + + getSearchIndex().clear(); + + const result = (await sdk.trigger("mem::search", { + query: "JWT whitespace auth middleware", + project: "shared-project", + })) as { results: Array<{ observation: { narrative: string } }> }; + + const combined = result.results.map((r) => r.observation.narrative).join(" "); + expect(combined).toContain("Work-only"); + expect(combined).not.toContain("Personal-only"); + }); + + it("enrich only pulls bug memories from the requested namespace", async () => { + await sdk.trigger("mem::remember", { + content: "Work bug: trim Authorization header before validation", + type: "bug", + files: ["src/auth.ts"], + project: "shared-project", + namespace: "work", + }); + await sdk.trigger("mem::remember", { + content: "Personal bug: trim Authorization header before validation", + type: "bug", + files: ["src/auth.ts"], + project: "shared-project", + namespace: "personal", + }); + + const result = (await sdk.trigger("mem::enrich", { + sessionId: "sess-work", + files: ["src/auth.ts"], + project: "shared-project", + namespace: "work", + })) as { context: string }; + + expect(result.context).toContain("Work bug"); + expect(result.context).not.toContain("Personal bug"); + }); + + it("generates separate profiles for the same project in different namespaces", async () => { + const work = (await sdk.trigger("mem::profile", { + project: "shared-project", + namespace: "work", + })) as { profile: { sessionCount: number; namespace?: string } }; + const personal = (await sdk.trigger("mem::profile", { + project: "shared-project", + namespace: "personal", + })) as { profile: { sessionCount: number; namespace?: string } }; + + expect(work.profile.sessionCount).toBe(2); + expect(work.profile.namespace).toBe("work"); + expect(personal.profile.sessionCount).toBe(1); + expect(personal.profile.namespace).toBe("personal"); + }); + + it("context for a namespaced project excludes sibling namespaces with the same project id", async () => { + const result = (await sdk.trigger("mem::context", { + sessionId: "sess-work", + project: "shared-project", + namespace: "work", + budget: 2000, + })) as { context: string }; + + expect(result.context).toContain("Work auth fix 2"); + expect(result.context).not.toContain("Personal auth fix"); + }); +});