diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index e1fb4d161..8bc0bafc4 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -98,6 +98,7 @@ func ensureImplicitSessionWithCWD(s *store.Store, sessionID, project string) err var ProfileAgent = map[string]bool{ "mem_save": true, // proactive save — referenced 17 times across protocols "mem_search": true, // search past memories — referenced 6 times + "mem_find_project": true, // find projects by memory content "mem_context": true, // recent context from previous sessions — referenced 10 times "mem_session_summary": true, // end-of-session summary — referenced 16 times "mem_session_start": true, // register session start @@ -295,6 +296,31 @@ func registerTools(srv *server.MCPServer, s *store.Store, cfg MCPConfig, allowli ) } + // ─── mem_find_project ───────────────────────────────────────────── + if shouldRegister("mem_find_project", allowlist) { + srv.AddTool( + mcp.NewTool("mem_find_project", + mcp.WithDescription("Search for projects containing relevant memories. Use this when you don't know which project holds a past decision. It returns the top matching projects, their match counts, and rank. You can then use mem_search with a specific project name to read those memories."), + mcp.WithTitleAnnotation("Find Projects"), + mcp.WithReadOnlyHintAnnotation(true), + mcp.WithDestructiveHintAnnotation(false), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithOpenWorldHintAnnotation(false), + mcp.WithString("query", + mcp.Required(), + mcp.Description("Search query — natural language or keywords to find across all projects"), + ), + mcp.WithString("match_mode", + mcp.Description("Token matching: \"all\" (default — every token must match, FTS5 AND) or \"any\" (any token matches)."), + ), + mcp.WithString("scope", + mcp.Description("Filter search results by scope: \"project\" (only team/project workspace memories), \"personal\" (personal logs/diary), or \"all\" (default — search across both)."), + ), + ), + handleFindProject(s, cfg), + ) + } + // ─── mem_save (profile: agent, core — always in context) ─────────── if shouldRegister("mem_save", allowlist) { srv.AddTool( @@ -1148,6 +1174,43 @@ func handleSearch(s *store.Store, cfg MCPConfig, activity *SessionActivity) serv } } +func handleFindProject(s *store.Store, cfg MCPConfig) server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + query, _ := req.GetArguments()["query"].(string) + matchMode, _ := req.GetArguments()["match_mode"].(string) + scope, _ := req.GetArguments()["scope"].(string) + + if query == "" { + return mcp.NewToolResultError("query is required"), nil + } + if matchMode != "" && matchMode != "all" && matchMode != "any" { + return mcp.NewToolResultError(fmt.Sprintf("invalid match_mode %q: must be \"all\" or \"any\"", matchMode)), nil + } + if scope != "" && scope != "all" && scope != "project" && scope != "personal" { + return mcp.NewToolResultError(fmt.Sprintf("invalid scope %q: must be \"all\", \"project\", or \"personal\"", scope)), nil + } + + limit := 10 // Fix limit as requested by minimalist approach + matches, err := s.SearchProjects(query, matchMode, scope, limit) + if err != nil { + return mcp.NewToolResultError("Project search failed: " + err.Error()), nil + } + + if len(matches) == 0 { + return mcp.NewToolResultText(fmt.Sprintf("No projects found matching %q.", query)), nil + } + + var b strings.Builder + fmt.Fprintf(&b, "Found %d project(s) matching %q:\n", len(matches), query) + for _, m := range matches { + fmt.Fprintf(&b, "- %s (%d matches, rank: %g)\n", m.Project, m.MatchCount, m.TopRank) + } + b.WriteString("\nUse mem_search with project: \"\" to explore these memories.") + + return mcp.NewToolResultText(b.String()), nil + } +} + func handlePin(s *store.Store, pinned bool) server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { id := int64(intArg(req, "id", 0)) diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 89fc21ed8..4243ca6b6 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -1611,7 +1611,7 @@ func TestResolveToolsAgentProfile(t *testing.T) { } expectedTools := []string{ - "mem_save", "mem_search", "mem_context", "mem_session_summary", + "mem_save", "mem_search", "mem_find_project", "mem_context", "mem_session_summary", "mem_session_start", "mem_session_end", "mem_get_observation", "mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt", "mem_update", // skills explicitly say "use mem_update when you have an exact ID to correct" @@ -2254,7 +2254,7 @@ func TestNewServerWithToolsNilRegistersAll(t *testing.T) { tools := srv.ListTools() allTools := []string{ - "mem_save", "mem_search", "mem_context", "mem_session_summary", + "mem_save", "mem_search", "mem_find_project", "mem_context", "mem_session_summary", "mem_session_start", "mem_session_end", "mem_get_observation", "mem_suggest_topic_key", "mem_capture_passive", "mem_save_prompt", "mem_update", "mem_delete", "mem_stats", "mem_timeline", "mem_merge_projects", @@ -2364,14 +2364,14 @@ func TestNewServerBackwardsCompatible(t *testing.T) { srv := NewServer(s) tools := srv.ListTools() - // 18 agent + 4 admin = 22 total. - if len(tools) != 22 { - t.Errorf("NewServer should register all 22 tools, got %d", len(tools)) + // 19 agent + 4 admin = 23 total. + if len(tools) != 23 { + t.Errorf("NewServer should register all 23 tools, got %d", len(tools)) } } func TestProfileConsistency(t *testing.T) { - // Verify that agent + admin = all 22 tools + // Verify that agent + admin = all 23 tools combined := make(map[string]bool) for tool := range ProfileAgent { combined[tool] = true @@ -2380,9 +2380,9 @@ func TestProfileConsistency(t *testing.T) { combined[tool] = true } - // 18 agent + 4 admin = 22 total. - if len(combined) != 22 { - t.Errorf("agent + admin should cover all 22 tools, got %d", len(combined)) + // 19 agent + 4 admin = 23 total. + if len(combined) != 23 { + t.Errorf("agent + admin should cover all 23 tools, got %d", len(combined)) } // Verify no overlap between profiles @@ -2710,9 +2710,9 @@ func TestNewServerWithConfig(t *testing.T) { t.Fatal("expected MCP server instance") } tools := srv.ListTools() - // Should have all 22 tools (18 agent + 4 admin). - if len(tools) != 22 { - t.Errorf("NewServerWithConfig should register all 22 tools, got %d", len(tools)) + // Should have all 23 tools (19 agent + 4 admin). + if len(tools) != 23 { + t.Errorf("NewServerWithConfig should register all 23 tools, got %d", len(tools)) } } @@ -7452,3 +7452,146 @@ func TestHandleSearch_MatchModeInvalidError(t *testing.T) { t.Fatalf("parameter-validation error must not contain query-advice suffix \"Try simpler keywords\", got: %s", text) } } + +func TestHandleFindProject(t *testing.T) { + s := newMCPTestStore(t) + + s.CreateSession("s1", "project-one", "/tmp/one") + s.CreateSession("s2", "project-two", "/tmp/two") + + // Insert some observations in different projects to test SearchProjects + _, err := s.AddObservation(store.AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "search test one", + Content: "project one search test", Project: "project-one", + }) + if err != nil { t.Fatal(err) } + _, err = s.AddObservation(store.AddObservationParams{ + SessionID: "s2", Type: "bugfix", Title: "search test two", + Content: "project two search test", Project: "project-two", + }) + if err != nil { t.Fatal(err) } + + // Insert a personal observation in project-one to test scope filtering + _, err = s.AddObservation(store.AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "personal search test", + Content: "project one personal thoughts", Project: "project-one", Scope: "personal", + }) + if err != nil { t.Fatal(err) } + + h := handleFindProject(s, MCPConfig{}) + + tests := []struct { + name string + query string + matchMode string + scope string + expectError bool + errorContains string + expectText string + }{ + { + name: "success exact match", + query: "project one", + matchMode: "", + expectText: "Found 1 project(s)", + }, + { + name: "success match any", + query: "project one test", + matchMode: "any", + expectText: "project-two", // Both contain test + }, + { + name: "missing query", + query: "", + matchMode: "", + expectError: true, + errorContains: "query is required", + }, + { + name: "invalid match_mode", + query: "test", + matchMode: "invalid-mode", + expectError: true, + errorContains: "invalid match_mode", + }, + { + name: "invalid scope", + query: "test", + matchMode: "", + scope: "invalid-scope", + expectError: true, + errorContains: "invalid scope", + }, + { + name: "no results", + query: "nonexistentstringthatwillnevermatch", + matchMode: "", + expectText: "No projects found matching", + }, + { + name: "scope project filters out personal ones", + query: "thoughts", + matchMode: "", + scope: "project", + expectText: "No projects found matching", + }, + { + name: "scope personal finds only personal ones", + query: "thoughts", + matchMode: "", + scope: "personal", + expectText: "project-one", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + args := map[string]any{"query": tc.query} + if tc.matchMode != "" { + args["match_mode"] = tc.matchMode + } + if tc.scope != "" { + args["scope"] = tc.scope + } + req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: args}} + res, err := h(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tc.expectError { + if !res.IsError { + t.Fatalf("expected error, got success") + } + text := callResultText(t, res) + if !strings.Contains(text, tc.errorContains) { + t.Fatalf("expected error containing %q, got %q", tc.errorContains, text) + } + } else { + if res.IsError { + t.Fatalf("expected success, got error: %s", callResultText(t, res)) + } + text := callResultText(t, res) + if tc.expectText != "" && !strings.Contains(text, tc.expectText) { + t.Fatalf("expected text containing %q, got %q", tc.expectText, text) + } + } + }) + } + + // Test error from store (e.g., closed db) + s.Close() + req := mcppkg.CallToolRequest{Params: mcppkg.CallToolParams{Arguments: map[string]any{"query": "test"}}} + res, err := h(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.IsError { + t.Fatalf("expected tool error due to closed store") + } + text := callResultText(t, res) + if !strings.Contains(text, "Project search failed") { + t.Fatalf("expected error text for store failure, got: %s", text) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 9c6537b98..b42ac22c8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -182,6 +182,12 @@ type SearchOptions struct { MatchMode string `json:"match_mode,omitempty"` // "all" (default) | "any" } +type ProjectMatch struct { + Project string + MatchCount int + TopRank float64 +} + type AddObservationParams struct { SessionID string `json:"session_id"` Type string `json:"type"` @@ -3235,6 +3241,71 @@ func (s *Store) Search(query string, opts SearchOptions) ([]SearchResult, error) return results, nil } +// ─── Search Projects ──────────────────────────────────────────────────────── + +// SearchProjects groups FTS5 search results by project to help route ambiguous searches. +func (s *Store) SearchProjects(query string, matchMode string, scope string, limit int) ([]ProjectMatch, error) { + if limit <= 0 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + var ftsQuery string + if matchMode == "any" { + ftsQuery = sanitizeFTSCandidates(query) + } else { + ftsQuery = sanitizeFTS(query) + } + if ftsQuery == "" { + return []ProjectMatch{}, nil + } + + var args []any + args = append(args, ftsQuery) + + scopeFilter := "" + if scope != "" && scope != "all" { + scopeFilter = " AND o.scope = ?" + args = append(args, normalizeScope(scope)) + } + + args = append(args, limit) + + sqlQ := fmt.Sprintf(` + SELECT project, COUNT(id) as match_count, MIN(rank) as top_rank + FROM ( + SELECT o.project, o.id, observations_fts.rank as rank + FROM observations_fts + JOIN observations o ON o.id = observations_fts.rowid + WHERE observations_fts MATCH ? AND o.deleted_at IS NULL AND o.project != ''%s + ) + GROUP BY project + ORDER BY top_rank ASC, match_count DESC, project ASC + LIMIT ? + `, scopeFilter) + + rows, err := s.queryItHook(s.db, sqlQ, args...) + if err != nil { + return nil, fmt.Errorf("search projects: %w", err) + } + defer rows.Close() + + var matches []ProjectMatch + for rows.Next() { + var p ProjectMatch + if err := rows.Scan(&p.Project, &p.MatchCount, &p.TopRank); err != nil { + return nil, err + } + matches = append(matches, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + return matches, nil +} + // ─── Stats ─────────────────────────────────────────────────────────────────── func (s *Store) Stats() (*Stats, error) { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5ed55ca63..2392b33ea 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -8830,3 +8830,198 @@ func TestSanitizeFTS(t *testing.T) { }) } } + +func TestSearchProjects(t *testing.T) { + st := newTestStore(t) + + st.CreateSession("s1", "project-a", "/tmp/a") + st.CreateSession("s2", "project-b", "/tmp/b") + st.CreateSession("s3", "project-c", "/tmp/c") + st.CreateSession("s4", "", "/tmp/d") + + // Seed 3 observations for project A + _, err := st.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "Fix auth token expiration", + Content: "The auth middleware was dropping tokens", Project: "project-a", + }) + if err != nil { t.Fatal(err) } + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "Auth token validation", + Content: "Middleware should validate auth tokens", Project: "project-a", + }) + if err != nil { t.Fatal(err) } + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "Minor fix", + Content: "Just a minor auth fix in the middleware", Project: "project-a", + }) + if err != nil { t.Fatal(err) } + + // Seed 1 highly relevant observation for project B + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s2", Type: "bugfix", Title: "Auth middleware completely rewritten", + Content: "Auth middleware auth middleware auth middleware tokens", Project: "project-b", + }) + if err != nil { t.Fatal(err) } + + // Seed an irrelevant observation for project C + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s3", Type: "feature", Title: "Database migration", + Content: "Added new tables", Project: "project-c", + }) + if err != nil { t.Fatal(err) } + + // Seed observation with blank project + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s4", Type: "bugfix", Title: "Auth issue in blank project", + Content: "The auth middleware failed here too", Project: "", + }) + if err != nil { t.Fatal(err) } + + // Seed observation in project-d and then delete it + st.CreateSession("s5", "project-d", "/tmp/e") + obsID, err := st.AddObservation(AddObservationParams{ + SessionID: "s5", Type: "bugfix", Title: "Auth issue to be deleted", + Content: "The auth middleware will be deleted", Project: "project-d", + }) + if err != nil { t.Fatal(err) } + err = st.DeleteObservation(obsID, false) + if err != nil { t.Fatal(err) } + + // Seed a personal observation for project-a to test scope filtering + _, err = st.AddObservation(AddObservationParams{ + SessionID: "s1", Type: "bugfix", Title: "Personal observation", + Content: "My personal auth thoughts", Project: "project-a", Scope: "personal", + }) + if err != nil { t.Fatal(err) } + + tests := []struct { + name string + query string + matchMode string + limit int + scope string + expectCounts map[string]int // project -> expected match count + expectErr bool + expectLen int + }{ + { + name: "match all default limit", + query: "auth middleware", + matchMode: "all", + limit: 10, + expectCounts: map[string]int{ + "project-a": 3, + "project-b": 1, + }, + expectLen: 2, + }, + { + name: "match any", + query: "auth tables", // auth matches a, b. tables matches c. + matchMode: "any", + limit: 10, + expectCounts: map[string]int{ + "project-a": 4, // 3 project + 1 personal + "project-b": 1, + "project-c": 1, + }, + expectLen: 3, + }, + { + name: "empty query", + query: "", + matchMode: "all", + limit: 10, + expectLen: 0, + }, + { + name: "zero or negative limit gets default 10", + query: "auth", + matchMode: "all", + limit: -5, + expectLen: 2, + }, + { + name: "limit upper bound 50", + query: "auth", + matchMode: "all", + limit: 100, // Should be clamped to 50 + expectLen: 2, + }, + { + name: "limit restricts results", + query: "auth", + matchMode: "all", + limit: 1, // Should return only top 1 project + expectLen: 1, + }, + { + name: "no match", + query: "nonexistentstring", + matchMode: "all", + limit: 10, + expectLen: 0, + }, + { + name: "scope project filters out personal ones", + query: "auth", + matchMode: "all", + scope: "project", + limit: 10, + expectCounts: map[string]int{ + "project-a": 3, // personal one excluded + "project-b": 1, + }, + expectLen: 2, + }, + { + name: "scope personal filters only personal ones", + query: "auth", + matchMode: "all", + scope: "personal", + limit: 10, + expectCounts: map[string]int{ + "project-a": 1, // only personal one included + }, + expectLen: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + matches, err := st.SearchProjects(tc.query, tc.matchMode, tc.scope, tc.limit) + if tc.expectErr { + if err == nil { + t.Fatalf("expected error but got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(matches) != tc.expectLen { + t.Fatalf("expected %d projects, got %d: %+v", tc.expectLen, len(matches), matches) + } + for _, m := range matches { + if m.Project == "" { + t.Errorf("expected no blank projects, got one") + } + if m.Project == "project-d" { + t.Errorf("expected deleted project-d to be excluded") + } + if expected, ok := tc.expectCounts[m.Project]; ok { + if m.MatchCount != expected { + t.Errorf("project %s: expected %d matches, got %d", m.Project, expected, m.MatchCount) + } + } + } + }) + } + + // Test error propagation + st.Close() + _, err = st.SearchProjects("auth", "all", "", 10) + if err == nil { + t.Fatalf("expected error from closed store, got nil") + } +}