diff --git a/src/Memorizer.IntegrationTests/McpToolsProjectScopingTests.cs b/src/Memorizer.IntegrationTests/McpToolsProjectScopingTests.cs index 62683ae..6e8d79b 100644 --- a/src/Memorizer.IntegrationTests/McpToolsProjectScopingTests.cs +++ b/src/Memorizer.IntegrationTests/McpToolsProjectScopingTests.cs @@ -451,4 +451,157 @@ public async Task MoveMemoryToUnfiled_MovesBackToUnfiled() } #endregion + + #region Workspace-Scoped Search Tests + + [Fact] + public async Task SearchWithMetadataEmbedding_WithWorkspaceId_RollsUpWorkspaceAndProjects() + { + var storage = _services.GetRequiredService(); + + var workspaceA = await storage.CreateWorkspaceAsync("Workspace A", null, cancellationToken: default); + var workspaceB = await storage.CreateWorkspaceAsync("Workspace B", null, cancellationToken: default); + var projectA1 = await storage.CreateProjectAsync(workspaceA.Id, "Project A1", null, cancellationToken: default); + var projectA2 = await storage.CreateProjectAsync(workspaceA.Id, "Project A2", null, cancellationToken: default); + var projectB1 = await storage.CreateProjectAsync(workspaceB.Id, "Project B1", null, cancellationToken: default); + + Memory? memoryOnWorkspaceA = null; + Memory? memoryInProjectA1 = null; + Memory? memoryInProjectA2 = null; + Memory? memoryOnWorkspaceB = null; + Memory? memoryInProjectB1 = null; + + try + { + memoryOnWorkspaceA = await storage.StoreMemory( + type: "reference", + content: "Workspace A shared note about flux capacitor configuration", + source: "test", + tags: null, + confidence: new Confidence(1.0), + title: "Workspace A Note", + owner: MemoryOwner.ForWorkspace(workspaceA.Id), + cancellationToken: default + ); + + memoryInProjectA1 = await storage.StoreMemory( + type: "reference", + content: "Project A1 details about flux capacitor configuration", + source: "test", + tags: null, + confidence: new Confidence(1.0), + title: "Project A1 Note", + owner: MemoryOwner.ForProject(projectA1.Id), + cancellationToken: default + ); + + memoryInProjectA2 = await storage.StoreMemory( + type: "reference", + content: "Project A2 details about flux capacitor configuration", + source: "test", + tags: null, + confidence: new Confidence(1.0), + title: "Project A2 Note", + owner: MemoryOwner.ForProject(projectA2.Id), + cancellationToken: default + ); + + memoryOnWorkspaceB = await storage.StoreMemory( + type: "reference", + content: "Workspace B unrelated note about flux capacitor configuration", + source: "test", + tags: null, + confidence: new Confidence(1.0), + title: "Workspace B Note", + owner: MemoryOwner.ForWorkspace(workspaceB.Id), + cancellationToken: default + ); + + memoryInProjectB1 = await storage.StoreMemory( + type: "reference", + content: "Project B1 unrelated note about flux capacitor configuration", + source: "test", + tags: null, + confidence: new Confidence(1.0), + title: "Project B1 Note", + owner: MemoryOwner.ForProject(projectB1.Id), + cancellationToken: default + ); + + var results = await storage.SearchWithMetadataEmbedding( + query: "flux capacitor configuration", + limit: 20, + minSimilarity: new SimilarityScore(0.0), + filterTags: null, + projectId: null, + includeUnassigned: false, + includeArchived: false, + includeSystem: false, + workspaceId: workspaceA.Id, + cancellationToken: default + ); + + var resultIds = results.Select(m => m.Id).ToHashSet(); + + Assert.Contains(memoryOnWorkspaceA.Id, resultIds); + Assert.Contains(memoryInProjectA1.Id, resultIds); + Assert.Contains(memoryInProjectA2.Id, resultIds); + Assert.DoesNotContain(memoryOnWorkspaceB.Id, resultIds); + Assert.DoesNotContain(memoryInProjectB1.Id, resultIds); + } + finally + { + if (memoryOnWorkspaceA != null) await storage.Delete(memoryOnWorkspaceA.Id, default); + if (memoryInProjectA1 != null) await storage.Delete(memoryInProjectA1.Id, default); + if (memoryInProjectA2 != null) await storage.Delete(memoryInProjectA2.Id, default); + if (memoryOnWorkspaceB != null) await storage.Delete(memoryOnWorkspaceB.Id, default); + if (memoryInProjectB1 != null) await storage.Delete(memoryInProjectB1.Id, default); + + await storage.DeleteProjectAsync(projectA1.Id, default); + await storage.DeleteProjectAsync(projectA2.Id, default); + await storage.DeleteProjectAsync(projectB1.Id, default); + await storage.DeleteWorkspaceAsync(workspaceA.Id, default); + await storage.DeleteWorkspaceAsync(workspaceB.Id, default); + } + } + + [Fact] + public async Task SearchWithMetadataEmbedding_WithBothProjectIdAndWorkspaceId_Throws() + { + var storage = _services.GetRequiredService(); + + await Assert.ThrowsAsync(() => storage.SearchWithMetadataEmbedding( + query: "anything", + limit: 5, + minSimilarity: new SimilarityScore(0.5), + filterTags: null, + projectId: ProjectId.New(), + includeUnassigned: false, + includeArchived: false, + includeSystem: false, + workspaceId: WorkspaceId.New(), + cancellationToken: default + )); + } + + [Fact] + public async Task HybridSearch_WithBothProjectIdAndWorkspaceId_Throws() + { + var storage = _services.GetRequiredService(); + + await Assert.ThrowsAsync(() => storage.HybridSearch( + query: "anything", + limit: 5, + minSimilarity: new SimilarityScore(0.5), + filterTags: null, + projectId: ProjectId.New(), + includeUnassigned: false, + includeArchived: false, + includeSystem: false, + workspaceId: WorkspaceId.New(), + cancellationToken: default + )); + } + + #endregion } diff --git a/src/Memorizer.IntegrationTests/TitleGenerationActorTests.cs b/src/Memorizer.IntegrationTests/TitleGenerationActorTests.cs index 5caceb6..6b175bb 100644 --- a/src/Memorizer.IntegrationTests/TitleGenerationActorTests.cs +++ b/src/Memorizer.IntegrationTests/TitleGenerationActorTests.cs @@ -210,13 +210,13 @@ public Task UpdateMemoryEmbeddings(MemoryId memoryId, Vector contentEmbedding, V public Task> SearchWithFullEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default) => throw new NotImplementedException("Test mock"); - public Task> SearchWithMetadataEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, CancellationToken cancellationToken = default) + public Task> SearchWithMetadataEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException("Test mock"); public Task<(List FullResults, List MetadataResults)> CompareSearchMethods(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, CancellationToken cancellationToken = default) => throw new NotImplementedException("Test mock"); - public Task> HybridSearch(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, CancellationToken cancellationToken = default) + public Task> HybridSearch(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException("Test mock"); // Versioning support diff --git a/src/Memorizer.UnitTests/Tools/MemoryToolsCanonicalUrlTests.cs b/src/Memorizer.UnitTests/Tools/MemoryToolsCanonicalUrlTests.cs index 25b8f94..7ac4cd8 100644 --- a/src/Memorizer.UnitTests/Tools/MemoryToolsCanonicalUrlTests.cs +++ b/src/Memorizer.UnitTests/Tools/MemoryToolsCanonicalUrlTests.cs @@ -1,8 +1,10 @@ +using Memorizer.Controllers; using Memorizer.Models; using Memorizer.Models.Enums; using Memorizer.Models.ValueTypes; using Memorizer.Services; using Memorizer.Settings; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging.Abstractions; using PostgMem.Tools; using Pgvector; @@ -74,6 +76,79 @@ public async Task Store_ShouldNotIncludeCanonicalUrl_WhenNotConfigured() Assert.DoesNotContain("View in web UI", result); } + [Fact] + public async Task Store_WithWorkspaceId_ShouldAssignMemoryToWorkspace() + { + // Arrange + var workspaceId = Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa"); + var fakeStorage = new FakeStorage + { + StoredMemory = CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Test Memory") + }; + var tools = CreateTools(fakeStorage, new FakeCanonicalUrlService()); + + // Act + var result = await tools.Store( + type: "reference", + text: "Test content", + source: "LLM", + title: "Test Memory", + workspaceId: workspaceId.ToString()); + + // Assert + Assert.Contains($"Assigned to workspace {workspaceId}", result); + Assert.True(fakeStorage.StoreMemoryCalled); + Assert.Equal(OwnerTypeEnum.Workspace, fakeStorage.LastStoredOwner?.Type); + Assert.Equal(workspaceId, fakeStorage.LastStoredOwner?.WorkspaceId?.Value); + } + + [Fact] + public async Task Store_WithProjectIdAndWorkspaceId_ReturnsValidationErrorAndDoesNotStore() + { + // Arrange + var fakeStorage = new FakeStorage + { + StoredMemory = CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Test Memory") + }; + var tools = CreateTools(fakeStorage, new FakeCanonicalUrlService()); + + // Act + var result = await tools.Store( + type: "reference", + text: "Test content", + source: "LLM", + title: "Test Memory", + projectId: "11111111-1111-1111-1111-111111111111", + workspaceId: "22222222-2222-2222-2222-222222222222"); + + // Assert + Assert.Contains("projectId and workspaceId are mutually exclusive", result); + Assert.False(fakeStorage.StoreMemoryCalled); + } + + [Fact] + public async Task Store_WithInvalidWorkspaceId_ReturnsValidationErrorAndDoesNotStore() + { + // Arrange + var fakeStorage = new FakeStorage + { + StoredMemory = CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Test Memory") + }; + var tools = CreateTools(fakeStorage, new FakeCanonicalUrlService()); + + // Act + var result = await tools.Store( + type: "reference", + text: "Test content", + source: "LLM", + title: "Test Memory", + workspaceId: "not-a-guid"); + + // Assert + Assert.Contains("workspaceId must be a valid GUID", result); + Assert.False(fakeStorage.StoreMemoryCalled); + } + [Fact] public async Task SearchMemories_ShouldIncludeCanonicalUrl_ForEachResult_WhenConfigured() { @@ -103,6 +178,121 @@ public async Task SearchMemories_ShouldIncludeCanonicalUrl_ForEachResult_WhenCon Assert.Equal(2, fakeUrlService.GetMemoryUrlCallCount); } + [Fact] + public async Task SearchMemories_ShouldPassWorkspaceId_ToHybridSearch() + { + // Arrange + var workspaceId = Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa"); + var fakeStorage = new FakeStorage + { + HybridSearchResults = new List + { + CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Memory 1") + } + }; + var tools = CreateTools(fakeStorage, new FakeCanonicalUrlService()); + + // Act + await tools.SearchMemories("test query", workspaceId: workspaceId.ToString()); + + // Assert + Assert.True(fakeStorage.HybridSearchCalled); + Assert.Null(fakeStorage.LastHybridSearchProjectId); + Assert.Equal(workspaceId, fakeStorage.LastHybridSearchWorkspaceId?.Value); + } + + [Fact] + public async Task SearchMemories_WithInvalidWorkspaceId_ReturnsValidationErrorAndDoesNotSearch() + { + // Arrange + var fakeStorage = new FakeStorage + { + HybridSearchResults = new List + { + CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Memory 1") + } + }; + var tools = CreateTools(fakeStorage, new FakeCanonicalUrlService()); + + // Act + var result = await tools.SearchMemories("test query", workspaceId: "not-a-guid"); + + // Assert + Assert.Contains("workspaceId must be a valid GUID", result); + Assert.False(fakeStorage.HybridSearchCalled); + } + + [Fact] + public async Task RestSearchMemories_WithWorkspaceId_PassesWorkspaceScopeToStorage() + { + // Arrange + var workspaceId = Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa"); + var fakeStorage = new FakeStorage + { + SearchWithMetadataEmbeddingResults = new List + { + CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Memory 1") + } + }; + var controller = new MemoryController(fakeStorage, new SimilaritySettings()); + + // Act + var result = await controller.SearchMemories("test query", workspaceId: workspaceId); + + // Assert + var ok = Assert.IsType(result.Result); + var items = Assert.IsType>(ok.Value); + Assert.Single(items); + Assert.True(fakeStorage.SearchWithMetadataEmbeddingCalled); + Assert.Null(fakeStorage.LastMetadataSearchProjectId); + Assert.Equal(workspaceId, fakeStorage.LastMetadataSearchWorkspaceId?.Value); + } + + [Fact] + public async Task RestSearchWithMetadataEmbedding_WithWorkspaceId_PassesWorkspaceScopeToStorage() + { + // Arrange + var workspaceId = Guid.Parse("b775bb37-4af5-46fe-ad14-7f6fba7889aa"); + var fakeStorage = new FakeStorage + { + SearchWithMetadataEmbeddingResults = new List + { + CreateTestMemory(new MemoryId(Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), "Memory 1") + } + }; + var controller = new MemoryController(fakeStorage, new SimilaritySettings()); + + // Act + var result = await controller.SearchWithMetadataEmbedding("test query", workspaceId: workspaceId); + + // Assert + var ok = Assert.IsType(result.Result); + var items = Assert.IsType>(ok.Value); + Assert.Single(items); + Assert.True(fakeStorage.SearchWithMetadataEmbeddingCalled); + Assert.Null(fakeStorage.LastMetadataSearchProjectId); + Assert.Equal(workspaceId, fakeStorage.LastMetadataSearchWorkspaceId?.Value); + } + + [Fact] + public async Task RestSearchMemories_WithProjectIdAndWorkspaceId_ReturnsBadRequest() + { + // Arrange + var fakeStorage = new FakeStorage(); + var controller = new MemoryController(fakeStorage, new SimilaritySettings()); + + // Act + var result = await controller.SearchMemories( + "test query", + projectId: Guid.Parse("11111111-1111-1111-1111-111111111111"), + workspaceId: Guid.Parse("22222222-2222-2222-2222-222222222222")); + + // Assert + var badRequest = Assert.IsType(result.Result); + Assert.Contains("mutually exclusive", Assert.IsType(badRequest.Value)); + Assert.False(fakeStorage.SearchWithMetadataEmbeddingCalled); + } + [Fact] public async Task Get_ShouldIncludeCanonicalUrl_WhenConfigured() { @@ -193,11 +383,24 @@ private class FakeStorage : IStorage public Memory? StoredMemory { get; set; } public Memory? RetrievedMemory { get; set; } public List? HybridSearchResults { get; set; } + public List? SearchWithMetadataEmbeddingResults { get; set; } public List? ManyResults { get; set; } + public bool StoreMemoryCalled { get; private set; } + public MemoryOwner? LastStoredOwner { get; private set; } + public bool HybridSearchCalled { get; private set; } + public ProjectId? LastHybridSearchProjectId { get; private set; } + public WorkspaceId? LastHybridSearchWorkspaceId { get; private set; } + public bool SearchWithMetadataEmbeddingCalled { get; private set; } + public ProjectId? LastMetadataSearchProjectId { get; private set; } + public WorkspaceId? LastMetadataSearchWorkspaceId { get; private set; } // Core memory operations used by tests public Task StoreMemory(string type, string content, string source, string[]? tags, Confidence confidence, string title, MemoryId? relatedTo = null, string? relationshipType = null, MemoryOwner? owner = null, ArchetypeEnum archetype = ArchetypeEnum.Document, CancellationToken cancellationToken = default) - => Task.FromResult(StoredMemory ?? throw new InvalidOperationException("StoredMemory not set")); + { + StoreMemoryCalled = true; + LastStoredOwner = owner; + return Task.FromResult(StoredMemory ?? throw new InvalidOperationException("StoredMemory not set")); + } public Task Get(MemoryId id, CancellationToken cancellationToken = default) => Task.FromResult(RetrievedMemory); @@ -205,8 +408,13 @@ public Task StoreMemory(string type, string content, string source, stri public Task> GetMany(IEnumerable ids, CancellationToken cancellationToken = default) => Task.FromResult(ManyResults ?? new List()); - public Task> HybridSearch(string query, int limit, SimilarityScore? minSimilarity, string[]? filterTags, ProjectId? projectId, bool includeUnassigned, bool includeArchived, bool includeSystem, CancellationToken cancellationToken) - => Task.FromResult(HybridSearchResults ?? new List()); + public Task> HybridSearch(string query, int limit, SimilarityScore? minSimilarity, string[]? filterTags, ProjectId? projectId, bool includeUnassigned, bool includeArchived, bool includeSystem, WorkspaceId? workspaceId, CancellationToken cancellationToken) + { + HybridSearchCalled = true; + LastHybridSearchProjectId = projectId; + LastHybridSearchWorkspaceId = workspaceId; + return Task.FromResult(HybridSearchResults ?? new List()); + } // Stub implementations for remaining interface members - grouped by category @@ -222,8 +430,13 @@ public Task> Search(string query, int limit = 10, SimilarityScore? => throw new NotImplementedException(); public Task> SearchWithFullEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task> SearchWithMetadataEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, CancellationToken cancellationToken = default) - => throw new NotImplementedException(); + public Task> SearchWithMetadataEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default) + { + SearchWithMetadataEmbeddingCalled = true; + LastMetadataSearchProjectId = projectId; + LastMetadataSearchWorkspaceId = workspaceId; + return Task.FromResult(SearchWithMetadataEmbeddingResults ?? new List()); + } public Task<(List FullResults, List MetadataResults)> CompareSearchMethods(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); diff --git a/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs b/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs index ea3d742..63c4c58 100644 --- a/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs +++ b/src/Memorizer.UnitTests/Tools/WorkspaceToolsCanonicalUrlTests.cs @@ -328,11 +328,11 @@ public Task UpdateMemoryEmbeddings(MemoryId memoryId, Vector contentEmbedding, V => throw new NotImplementedException(); public Task> SearchWithFullEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, bool includeArchived = false, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task> SearchWithMetadataEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, CancellationToken cancellationToken = default) + public Task> SearchWithMetadataEmbedding(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task<(List FullResults, List MetadataResults)> CompareSearchMethods(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); - public Task> HybridSearch(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, CancellationToken cancellationToken = default) + public Task> HybridSearch(string query, int limit = 10, SimilarityScore? minSimilarity = null, string[]? filterTags = null, ProjectId? projectId = null, bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default) => throw new NotImplementedException(); public Task UpdateMemoryArchetypeAsync(MemoryId memoryId, ArchetypeEnum newArchetype, CancellationToken cancellationToken = default) => throw new NotImplementedException(); diff --git a/src/Memorizer/Controllers/MemoryController.cs b/src/Memorizer/Controllers/MemoryController.cs index b82e4a0..c2a0ae6 100644 --- a/src/Memorizer/Controllers/MemoryController.cs +++ b/src/Memorizer/Controllers/MemoryController.cs @@ -537,12 +537,32 @@ public async Task>> SearchMemories( [FromQuery] string query, [FromQuery] double minSimilarity = 0.7, [FromQuery] int limit = 10, - [FromQuery] string[]? filterTags = null) + [FromQuery] string[]? filterTags = null, + [FromQuery] Guid? projectId = null, + [FromQuery] bool includeUnassigned = false, + [FromQuery] bool includeArchived = false, + [FromQuery] Guid? workspaceId = null) { if (string.IsNullOrWhiteSpace(query)) return BadRequest("Query is required."); + + if (projectId.HasValue && workspaceId.HasValue) + return BadRequest("projectId and workspaceId are mutually exclusive search scopes."); + + ProjectId? typedProjectId = projectId.HasValue ? new ProjectId(projectId.Value) : null; + WorkspaceId? typedWorkspaceId = workspaceId.HasValue ? new WorkspaceId(workspaceId.Value) : null; + // Use metadata embeddings by default for better keyword query performance - var results = await _storage.SearchWithMetadataEmbedding(query, limit, new SimilarityScore(minSimilarity), filterTags); + var results = await _storage.SearchWithMetadataEmbedding( + query, + limit, + new SimilarityScore(minSimilarity), + filterTags, + typedProjectId, + includeUnassigned, + includeArchived, + includeSystem: false, + workspaceId: typedWorkspaceId); return Ok(results.Select(MemoryListItem.FromMemory).ToList()); } @@ -570,11 +590,31 @@ public async Task>> SearchWithMetadataEmbeddin [FromQuery] string query, [FromQuery] double minSimilarity = 0.7, [FromQuery] int limit = 10, - [FromQuery] string[]? filterTags = null) + [FromQuery] string[]? filterTags = null, + [FromQuery] Guid? projectId = null, + [FromQuery] bool includeUnassigned = false, + [FromQuery] bool includeArchived = false, + [FromQuery] Guid? workspaceId = null) { if (string.IsNullOrWhiteSpace(query)) return BadRequest("Query is required."); - var results = await _storage.SearchWithMetadataEmbedding(query, limit, new SimilarityScore(minSimilarity), filterTags); + + if (projectId.HasValue && workspaceId.HasValue) + return BadRequest("projectId and workspaceId are mutually exclusive search scopes."); + + ProjectId? typedProjectId = projectId.HasValue ? new ProjectId(projectId.Value) : null; + WorkspaceId? typedWorkspaceId = workspaceId.HasValue ? new WorkspaceId(workspaceId.Value) : null; + + var results = await _storage.SearchWithMetadataEmbedding( + query, + limit, + new SimilarityScore(minSimilarity), + filterTags, + typedProjectId, + includeUnassigned, + includeArchived, + includeSystem: false, + workspaceId: typedWorkspaceId); return Ok(results.Select(MemoryListItem.FromMemory).ToList()); } @@ -967,4 +1007,4 @@ public class OwnerDto { public string Type { get; set; } = string.Empty; public Guid Id { get; set; } -} \ No newline at end of file +} diff --git a/src/Memorizer/Services/Memory.cs b/src/Memorizer/Services/Memory.cs index 90de083..f41666f 100644 --- a/src/Memorizer/Services/Memory.cs +++ b/src/Memorizer/Services/Memory.cs @@ -140,6 +140,7 @@ Task UpdateMemoryOwner( bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, + WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default ); @@ -160,6 +161,7 @@ Task UpdateMemoryOwner( bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, + WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default ); @@ -1324,9 +1326,11 @@ FROM memories bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, + WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default ) { + EnsureOwnerScopeExclusive(projectId, workspaceId); var effectiveMinSimilarity = minSimilarity ?? SimilarityScore.DefaultThreshold; // Generate embedding for the query @@ -1347,22 +1351,9 @@ FROM memories int fetchLimit = limit * 2; // Build owner filter clause - string ownerFilter = ""; - if (projectId.HasValue) - { - if (includeUnassigned) - { - // Include both project-owned and unfiled memories - ownerFilter = @"AND ((owner_type = 1 AND owner_id = @projectId) - OR (owner_type = 0 AND owner_id = '00000000-0000-0000-0000-000000000000'))"; - } - else - { - // Only project-owned memories - ownerFilter = "AND owner_type = 1 AND owner_id = @projectId"; - } - } - // If no projectId specified, search across all memories (original behavior) + string ownerFilter = projectId.HasValue + ? BuildOwnerFilter(projectId, includeUnassigned) + : BuildWorkspaceOwnerFilter(workspaceId); // Build archetype filter // ArchetypeEnum values: Document=0, Record=1, Archived=2, System=3 @@ -1393,6 +1384,10 @@ FROM memories { cmd.Parameters.AddWithValue("projectId", projectId.Value.Value); } + if (workspaceId.HasValue) + { + cmd.Parameters.AddWithValue("workspaceId", workspaceId.Value.Value); + } List memories = []; List memoryIds = new(); @@ -1450,7 +1445,7 @@ FROM memories ) { var fullEmbeddingResults = await SearchWithFullEmbedding(query, limit, minSimilarity, filterTags, includeArchived: false, cancellationToken); - var metadataEmbeddingResults = await SearchWithMetadataEmbedding(query, limit, minSimilarity, filterTags, projectId: null, includeUnassigned: false, includeArchived: false, includeSystem: false, cancellationToken); + var metadataEmbeddingResults = await SearchWithMetadataEmbedding(query, limit, minSimilarity, filterTags, projectId: null, includeUnassigned: false, includeArchived: false, includeSystem: false, workspaceId: null, cancellationToken: cancellationToken); return (fullEmbeddingResults, metadataEmbeddingResults); } @@ -1467,6 +1462,22 @@ private static string BuildOwnerFilter(ProjectId? projectId, bool includeUnassig return "AND owner_type = 1 AND owner_id = @projectId"; } + private static string BuildWorkspaceOwnerFilter(WorkspaceId? workspaceId) + { + if (!workspaceId.HasValue) return ""; + + return @"AND ((owner_type = 0 AND owner_id = @workspaceId) + OR (owner_type = 1 AND owner_id IN (SELECT id FROM projects WHERE workspace_id = @workspaceId)))"; + } + + private static void EnsureOwnerScopeExclusive(ProjectId? projectId, WorkspaceId? workspaceId) + { + if (projectId.HasValue && workspaceId.HasValue) + { + throw new ArgumentException("projectId and workspaceId are mutually exclusive search scopes.", nameof(workspaceId)); + } + } + private static string BuildArchetypeFilter(bool includeArchived, bool includeSystem) { return (includeArchived, includeSystem) switch @@ -1505,16 +1516,21 @@ private static string BuildPrefixTsQuery(string query) bool includeUnassigned = false, bool includeArchived = false, bool includeSystem = false, + WorkspaceId? workspaceId = null, CancellationToken cancellationToken = default ) { + EnsureOwnerScopeExclusive(projectId, workspaceId); + // Generate embedding for the query float[] queryEmbedding = await _embeddingService.Generate(query, cancellationToken); await using NpgsqlConnection connection = await _dataSource.OpenConnectionAsync(cancellationToken); int fetchLimit = Math.Max(limit * 3, 30); - string ownerFilter = BuildOwnerFilter(projectId, includeUnassigned); + string ownerFilter = projectId.HasValue + ? BuildOwnerFilter(projectId, includeUnassigned) + : BuildWorkspaceOwnerFilter(workspaceId); string archetypeFilter = BuildArchetypeFilter(includeArchived, includeSystem); // Leg 1: Vector search (metadata embedding, no hard distance threshold) @@ -1537,6 +1553,8 @@ WHERE embedding_metadata IS NOT NULL cmd.Parameters.AddWithValue("fetchLimit", fetchLimit); if (projectId.HasValue) cmd.Parameters.AddWithValue("projectId", projectId.Value.Value); + if (workspaceId.HasValue) + cmd.Parameters.AddWithValue("workspaceId", workspaceId.Value.Value); await using var reader = await cmd.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) @@ -1566,6 +1584,8 @@ ORDER BY fts_rank DESC { cmd.Parameters.AddWithValue("tsquery", tsquery); cmd.Parameters.AddWithValue("fetchLimit", fetchLimit); + if (workspaceId.HasValue) + cmd.Parameters.AddWithValue("workspaceId", workspaceId.Value.Value); if (projectId.HasValue) cmd.Parameters.AddWithValue("projectId", projectId.Value.Value); diff --git a/src/Memorizer/Tools/MemoryTools.cs b/src/Memorizer/Tools/MemoryTools.cs index a41feb0..24eb4f9 100644 --- a/src/Memorizer/Tools/MemoryTools.cs +++ b/src/Memorizer/Tools/MemoryTools.cs @@ -40,7 +40,8 @@ public async Task Store( [Description("Confidence score for the memory (0.0 to 1.0)")] double confidence = 1.0, [Description("Optionally, the ID of a related memory. Use this to link related reference materials, how-tos, or examples.")] string? relatedTo = null, [Description("Optionally, the type of relationship to create (e.g., 'example-of', 'explains', 'related-to'). Use relationships to connect related knowledge.")] string? relationshipType = null, - [Description("Optional project ID to assign this memory to. If not provided, memory is stored in the Unfiled workspace. Use ListProjects to find available projects.")] string? projectId = null, + [Description("Optional project ID to assign this memory to. If not provided, memory is stored in the Unfiled workspace. Use ListProjects to find available projects. Mutually exclusive with workspaceId.")] string? projectId = null, + [Description("Optional workspace ID to assign this memory directly to a workspace. Mutually exclusive with projectId.")] string? workspaceId = null, [Description("Memory archetype: 'document' for living, editable content (default) or 'record' for historical, immutable records like work logs.")] string archetype = "document", CancellationToken cancellationToken = default ) @@ -49,13 +50,29 @@ public async Task Store( var archetypeEnum = ArchetypeEnumExtensions.ParseArchetype(archetype); // Parse optional Guid parameters defensively — MCP clients may send empty strings or "null" - var parsedProjectId = ParseOptionalGuid(projectId); + if (!TryParseOptionalGuid(projectId, out var parsedProjectId)) + { + return "projectId must be a valid GUID, empty, or null."; + } + + if (!TryParseOptionalGuid(workspaceId, out var parsedWorkspaceId)) + { + return "workspaceId must be a valid GUID, empty, or null."; + } + + if (parsedProjectId.HasValue && parsedWorkspaceId.HasValue) + { + return "projectId and workspaceId are mutually exclusive memory owners. Provide only one."; + } + var parsedRelatedTo = ParseOptionalGuid(relatedTo); - // Determine owner based on projectId + // Determine owner based on projectId / workspaceId MemoryOwner? owner = parsedProjectId.HasValue ? MemoryOwner.ForProject(new ProjectId(parsedProjectId.Value)) - : null; // null defaults to Unfiled in storage layer + : parsedWorkspaceId.HasValue + ? MemoryOwner.ForWorkspace(new WorkspaceId(parsedWorkspaceId.Value)) + : null; // null defaults to Unfiled in storage layer // Create new memory var memory = await _storage.StoreMemory( @@ -76,9 +93,12 @@ public async Task Store( await _storage.CreateRelationship(memory.Id, (MemoryId)parsedRelatedTo.Value, relationshipType, cancellationToken); } - var locationInfo = owner != null - ? $"Assigned to project {parsedProjectId}." - : "Stored in Unfiled workspace."; + var locationInfo = owner?.Type switch + { + OwnerTypeEnum.Project => $"Assigned to project {parsedProjectId}.", + OwnerTypeEnum.Workspace => $"Assigned to workspace {parsedWorkspaceId}.", + _ => "Stored in Unfiled workspace." + }; var urlInfo = _canonicalUrlService.IsConfigured ? $"\n\nView in web UI: {_canonicalUrlService.GetMemoryUrl(memory.Id)}" @@ -277,9 +297,10 @@ public async Task SearchMemories( [Description("Maximum number of results to return")] int limit = 10, [Description("Minimum similarity threshold (0.0 to 1.0)")] double minSimilarity = 0.7, [Description("Optional tags to filter memories (e.g., 'reference', 'how-to', 'coding-standard')")] string[]? filterTags = null, - [Description("Optional project ID to scope search to. If provided, only searches memories assigned to this project. Use ListProjects to find available projects.")] string? projectId = null, + [Description("Optional project ID to scope search to. If provided, only searches memories assigned to this project. Use ListProjects to find available projects. Mutually exclusive with workspaceId.")] string? projectId = null, [Description("When projectId is specified, also include memories in the Unfiled workspace. Useful for finding unorganized content that might be relevant.")] bool includeUnassigned = false, [Description("Include archived memories in search results. Default is false (archived memories are hidden).")] bool includeArchived = false, + [Description("Optional workspace ID to scope search to. If provided, searches memories owned directly by the workspace plus all memories owned by projects within it (direct children only, not sub-workspaces). Mutually exclusive with projectId.")] string? workspaceId = null, CancellationToken cancellationToken = default ) { @@ -293,13 +314,28 @@ public async Task SearchMemories( {"query.minSimilarity", minSimilarity.ToString()}, {"query.filterTags", filterTags != null ? string.Join(", ", filterTags) : "none"}, {"query.projectId", projectId ?? "none"}, + {"query.workspaceId", workspaceId ?? "none"}, {"query.includeUnassigned", includeUnassigned.ToString()}, {"query.includeArchived", includeArchived.ToString()} })); - // Convert projectId to typed ProjectId — parse defensively since MCP clients may send empty strings - var parsedProjectId = ParseOptionalGuid(projectId); + // Convert projectId / workspaceId to typed IDs — parse defensively since MCP clients may send empty strings + if (!TryParseOptionalGuid(projectId, out var parsedProjectId)) + { + return "projectId must be a valid GUID, empty, or null."; + } + + if (!TryParseOptionalGuid(workspaceId, out var parsedWorkspaceId)) + { + return "workspaceId must be a valid GUID, empty, or null."; + } + + if (parsedProjectId.HasValue && parsedWorkspaceId.HasValue) + { + return "projectId and workspaceId are mutually exclusive search scopes. Provide only one."; + } ProjectId? typedProjectId = parsedProjectId.HasValue ? new ProjectId(parsedProjectId.Value) : null; + WorkspaceId? typedWorkspaceId = parsedWorkspaceId.HasValue ? new WorkspaceId(parsedWorkspaceId.Value) : null; // Use hybrid search combining vector similarity + PostgreSQL full-text search via RRF List memories = await _storage.HybridSearch( @@ -311,6 +347,7 @@ public async Task SearchMemories( includeUnassigned, includeArchived, includeSystem: false, + typedWorkspaceId, cancellationToken ); @@ -342,6 +379,7 @@ public async Task SearchMemories( includeUnassigned, includeArchived, includeSystem: false, + typedWorkspaceId, cancellationToken ); @@ -1205,4 +1243,15 @@ public async Task ListArchived( if (value.Equals("null", StringComparison.OrdinalIgnoreCase)) return null; return Guid.TryParse(value, out var guid) ? guid : null; } + + private static bool TryParseOptionalGuid(string? value, out Guid? guid) + { + guid = null; + if (string.IsNullOrWhiteSpace(value)) return true; + if (value.Equals("null", StringComparison.OrdinalIgnoreCase)) return true; + if (!Guid.TryParse(value, out var parsed)) return false; + + guid = parsed; + return true; + } }